diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 58c24181d3..b51ecb4f7e 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -31,6 +31,12 @@ "commands": [ "CodeFileSanity" ] + }, + "ppy.localisationanalyser.tools": { + "version": "2021.524.0", + "commands": [ + "localisation" + ] } } } \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index a5f7795882..f4d7e08d08 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,14 +10,6 @@ trim_trailing_whitespace = true #Roslyn naming styles -#PascalCase for public and protected members -dotnet_naming_style.pascalcase.capitalization = pascal_case -dotnet_naming_symbols.public_members.applicable_accessibilities = public,internal,protected,protected_internal,private_protected -dotnet_naming_symbols.public_members.applicable_kinds = property,method,field,event -dotnet_naming_rule.public_members_pascalcase.severity = error -dotnet_naming_rule.public_members_pascalcase.symbols = public_members -dotnet_naming_rule.public_members_pascalcase.style = pascalcase - #camelCase for private members dotnet_naming_style.camelcase.capitalization = camel_case @@ -194,4 +186,7 @@ dotnet_diagnostic.IDE0068.severity = none dotnet_diagnostic.IDE0069.severity = none #Disable operator overloads requiring alternate named methods -dotnet_diagnostic.CA2225.severity = none \ No newline at end of file +dotnet_diagnostic.CA2225.severity = none + +# Banned APIs +dotnet_diagnostic.RS0030.severity = error diff --git a/.github/ISSUE_TEMPLATE/01-bug-issues.md b/.github/ISSUE_TEMPLATE/01-bug-issues.md index 0b80ce44dd..7026179259 100644 --- a/.github/ISSUE_TEMPLATE/01-bug-issues.md +++ b/.github/ISSUE_TEMPLATE/01-bug-issues.md @@ -1,7 +1,18 @@ --- name: Bug Report -about: Issues regarding encountered bugs. +about: Report a bug or crash to desktop --- + + + + **Describe the bug:** **Screenshots or videos showing encountered issue:** @@ -9,8 +20,11 @@ about: Issues regarding encountered bugs. **osu!lazer version:** **Logs:** + diff --git a/.github/ISSUE_TEMPLATE/02-crash-issues.md b/.github/ISSUE_TEMPLATE/02-crash-issues.md deleted file mode 100644 index ada8de73c0..0000000000 --- a/.github/ISSUE_TEMPLATE/02-crash-issues.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: Crash Report -about: Issues regarding crashes or permanent freezes. ---- -**Describe the crash:** - -**Screenshots or videos showing encountered issue:** - -**osu!lazer version:** - -**Logs:** - - -**Computer Specifications:** diff --git a/.github/ISSUE_TEMPLATE/03-feature-request-issues.md b/.github/ISSUE_TEMPLATE/03-feature-request-issues.md deleted file mode 100644 index 54c4ff94e5..0000000000 --- a/.github/ISSUE_TEMPLATE/03-feature-request-issues.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: Feature Request -about: Features you would like to see in the game! ---- -**Describe the new feature:** - -**Proposal designs of the feature:** diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 69baeee60c..c62231e8e0 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,12 @@ blank_issues_enabled: false contact_links: + - name: Suggestions or feature request + url: https://github.com/ppy/osu/discussions/categories/ideas + about: Got something you think should change or be added? Search for or start a new discussion! + - name: Help + url: https://github.com/ppy/osu/discussions/categories/q-a + about: osu! not working as you'd expect? Not sure it's a bug? Check the Q&A section! - name: osu!stable issues url: https://github.com/ppy/osu-stable-issues - about: For issues regarding osu!stable (not osu!lazer), open them here. + about: For osu!stable bugs (not osu!lazer), check out the dedicated repository. Note that we only accept serious bug reports. + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..9e9af23b27 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,46 @@ +version: 2 +updates: +- package-ecosystem: nuget + directory: "/" + schedule: + interval: monthly + time: "17:00" + open-pull-requests-limit: 99 + ignore: + - dependency-name: Microsoft.EntityFrameworkCore.Design + versions: + - "> 2.2.6" + - dependency-name: Microsoft.EntityFrameworkCore.Sqlite + versions: + - "> 2.2.6" + - dependency-name: Microsoft.EntityFrameworkCore.Sqlite.Core + versions: + - "> 2.2.6" + - dependency-name: Microsoft.Extensions.DependencyInjection + versions: + - ">= 5.a, < 6" + - dependency-name: NUnit3TestAdapter + versions: + - ">= 3.16.a, < 3.17" + - dependency-name: Microsoft.NET.Test.Sdk + versions: + - 16.9.1 + - dependency-name: Microsoft.Extensions.DependencyInjection + versions: + - 3.1.11 + - 3.1.12 + - dependency-name: Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson + versions: + - 3.1.11 + - dependency-name: Microsoft.NETCore.Targets + versions: + - 5.0.0 + - dependency-name: Microsoft.AspNetCore.SignalR.Protocols.MessagePack + versions: + - 5.0.2 + - dependency-name: NUnit + versions: + - 3.13.1 + - dependency-name: Microsoft.AspNetCore.SignalR.Client + versions: + - 3.1.11 diff --git a/.idea/.idea.osu.Desktop/.idea/indexLayout.xml b/.idea/.idea.osu.Desktop/.idea/indexLayout.xml index 27ba142e96..7b08163ceb 100644 --- a/.idea/.idea.osu.Desktop/.idea/indexLayout.xml +++ b/.idea/.idea.osu.Desktop/.idea/indexLayout.xml @@ -1,6 +1,6 @@ - + diff --git a/.idea/.idea.osu.Desktop/.idea/modules.xml b/.idea/.idea.osu.Desktop/.idea/modules.xml deleted file mode 100644 index 680312ad27..0000000000 --- a/.idea/.idea.osu.Desktop/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu___legacy_osuTK_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu___legacy_osuTK_.xml deleted file mode 100644 index 9ece926b34..0000000000 --- a/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu___legacy_osuTK_.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c327f01b3..e14be20642 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ Issues, bug reports and feature suggestions are welcomed, though please keep in * the in-game logs, which are located at: * `%AppData%/osu/logs` (on Windows), * `~/.local/share/osu/logs` (on Linux and macOS), - * `Android/Data/sh.ppy.osulazer/logs` (on Android), + * `Android/data/sh.ppy.osulazer/files/logs` (on Android), * on iOS they can be obtained by connecting your device to your desktop and [copying the `logs` directory from the app's own document storage using iTunes](https://support.apple.com/en-us/HT201301#copy-to-computer), * your system specifications (including the operating system and platform you are playing on), * a reproduction scenario (list of steps you have performed leading up to the occurrence of the bug), diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 47839608c9..46c50dbfa2 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -7,3 +7,4 @@ M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText. M:osu.Framework.Bindables.IBindableList`1.GetBoundCopy();Fails on iOS. Use manual ctor + BindTo instead. (see https://github.com/mono/mono/issues/19900) T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal extension methods. T:Microsoft.EntityFrameworkCore.Internal.TypeExtensions;Don't use internal extension methods. +M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.HasFlagFast() instead. \ No newline at end of file diff --git a/CodeAnalysis/osu.ruleset b/CodeAnalysis/osu.ruleset index d497365f87..6a99e230d1 100644 --- a/CodeAnalysis/osu.ruleset +++ b/CodeAnalysis/osu.ruleset @@ -30,7 +30,7 @@ - + diff --git a/Directory.Build.props b/Directory.Build.props index 2e1873a9ed..53ad973e47 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ - + $(MSBuildThisFileDirectory)CodeAnalysis\osu.ruleset diff --git a/README.md b/README.md index e09b4d86a5..3054f19e79 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Commo This project is under heavy development, but is in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update. -**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passses come at the end of development, preceeded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to the stable releases of osu! (found on the website). We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet. +**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passes come at the end of development, preceded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to the stable releases of osu! (found on the website). We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet. We are accepting bug reports (please report with as much detail as possible and follow the existing issue templates). Feature requests are also welcome, but understand that our focus is on completing the game to feature parity before adding new features. A few resources are available as starting points to getting involved and understanding the project: @@ -41,7 +41,7 @@ If your platform is not listed above, there is still a chance you can manually b ## Developing a custom ruleset -osu! is designed to have extensible modular gameplay modes, called "rulesets". Building one of these allows a developer to harness the power of osu! for their own game style. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu-templates). +osu! is designed to have extensible modular gameplay modes, called "rulesets". Building one of these allows a developer to harness the power of osu! for their own game style. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates). You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/issues/5852). @@ -97,7 +97,7 @@ Before committing your code, please run a code formatter. This can be achieved b We have adopted some cross-platform, compiler integrated analyzers. They can provide warnings when you are editing, building inside IDE or from command line, as-if they are provided by the compiler itself. -JetBrains ReSharper InspectCode is also used for wider rule sets. You can run it from PowerShell with `.\InspectCode.ps1`, which is [only supported on Windows](https://youtrack.jetbrains.com/issue/RSRP-410004). Alternatively, you can install ReSharper or use Rider to get inline support in your IDE of choice. +JetBrains ReSharper InspectCode is also used for wider rule sets. You can run it from PowerShell with `.\InspectCode.ps1`. Alternatively, you can install ReSharper or use Rider to get inline support in your IDE of choice. ## Contributing diff --git a/Templates/Directory.Build.props b/Templates/Directory.Build.props new file mode 100644 index 0000000000..0e470106e8 --- /dev/null +++ b/Templates/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/Templates/README.md b/Templates/README.md new file mode 100644 index 0000000000..cf25a89273 --- /dev/null +++ b/Templates/README.md @@ -0,0 +1,21 @@ +# Templates + +Templates for use when creating osu! dependent projects. Create a fully-testable (and ready for git) custom ruleset in just two lines. + +## Usage + +```bash +# install (or update) templates package. +# this only needs to be done once +dotnet new -i ppy.osu.Game.Templates + +# create an empty freeform ruleset +dotnet new ruleset -n MyCoolRuleset +# create an empty scrolling ruleset (which provides the basics for a scrolling ←↑→↓ ruleset) +dotnet new ruleset-scrolling -n MyCoolRuleset + +# ..or start with a working sample freeform game +dotnet new ruleset-example -n MyCoolWorkingRuleset +# ..or a working sample scrolling game +dotnet new ruleset-scrolling-example -n MyCoolWorkingRuleset +``` diff --git a/Templates/Rulesets/ruleset-empty/.editorconfig b/Templates/Rulesets/ruleset-empty/.editorconfig new file mode 100644 index 0000000000..f3badda9b3 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/.editorconfig @@ -0,0 +1,200 @@ +# EditorConfig is awesome: http://editorconfig.org +root = true + +[*.cs] +end_of_line = crlf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +#Roslyn naming styles + +#PascalCase for public and protected members +dotnet_naming_style.pascalcase.capitalization = pascal_case +dotnet_naming_symbols.public_members.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_members.applicable_kinds = property,method,field,event +dotnet_naming_rule.public_members_pascalcase.severity = error +dotnet_naming_rule.public_members_pascalcase.symbols = public_members +dotnet_naming_rule.public_members_pascalcase.style = pascalcase + +#camelCase for private members +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_symbols.private_members.applicable_accessibilities = private +dotnet_naming_symbols.private_members.applicable_kinds = property,method,field,event +dotnet_naming_rule.private_members_camelcase.severity = warning +dotnet_naming_rule.private_members_camelcase.symbols = private_members +dotnet_naming_rule.private_members_camelcase.style = camelcase + +dotnet_naming_symbols.local_function.applicable_kinds = local_function +dotnet_naming_rule.local_function_camelcase.severity = warning +dotnet_naming_rule.local_function_camelcase.symbols = local_function +dotnet_naming_rule.local_function_camelcase.style = camelcase + +#all_lower for private and local constants/static readonlys +dotnet_naming_style.all_lower.capitalization = all_lower +dotnet_naming_style.all_lower.word_separator = _ + +dotnet_naming_symbols.private_constants.applicable_accessibilities = private +dotnet_naming_symbols.private_constants.required_modifiers = const +dotnet_naming_symbols.private_constants.applicable_kinds = field +dotnet_naming_rule.private_const_all_lower.severity = warning +dotnet_naming_rule.private_const_all_lower.symbols = private_constants +dotnet_naming_rule.private_const_all_lower.style = all_lower + +dotnet_naming_symbols.private_static_readonly.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly.required_modifiers = static,readonly +dotnet_naming_symbols.private_static_readonly.applicable_kinds = field +dotnet_naming_rule.private_static_readonly_all_lower.severity = warning +dotnet_naming_rule.private_static_readonly_all_lower.symbols = private_static_readonly +dotnet_naming_rule.private_static_readonly_all_lower.style = all_lower + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.required_modifiers = const +dotnet_naming_rule.local_const_all_lower.severity = warning +dotnet_naming_rule.local_const_all_lower.symbols = local_constants +dotnet_naming_rule.local_const_all_lower.style = all_lower + +#ALL_UPPER for non private constants/static readonlys +dotnet_naming_style.all_upper.capitalization = all_upper +dotnet_naming_style.all_upper.word_separator = _ + +dotnet_naming_symbols.public_constants.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_constants.required_modifiers = const +dotnet_naming_symbols.public_constants.applicable_kinds = field +dotnet_naming_rule.public_const_all_upper.severity = warning +dotnet_naming_rule.public_const_all_upper.symbols = public_constants +dotnet_naming_rule.public_const_all_upper.style = all_upper + +dotnet_naming_symbols.public_static_readonly.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_static_readonly.required_modifiers = static,readonly +dotnet_naming_symbols.public_static_readonly.applicable_kinds = field +dotnet_naming_rule.public_static_readonly_all_upper.severity = warning +dotnet_naming_rule.public_static_readonly_all_upper.symbols = public_static_readonly +dotnet_naming_rule.public_static_readonly_all_upper.style = all_upper + +#Roslyn formating options + +#Formatting - indentation options +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +#Formatting - new line options +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_open_brace = all +#csharp_new_line_before_members_in_anonymous_types = true +#csharp_new_line_before_members_in_object_initializers = true # Currently no effect in VS/dotnet format (16.4), and makes Rider confusing +csharp_new_line_between_query_expression_clauses = true + +#Formatting - organize using options +dotnet_sort_system_directives_first = true + +#Formatting - spacing options +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false + +#Formatting - wrapping options +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#Roslyn language styles + +#Style - this. qualification +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +#Style - type names +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_for_built_in_types = true:none +csharp_style_var_elsewhere = true:silent + +#Style - modifiers +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +csharp_preferred_modifier_order = public,private,protected,internal,new,abstract,virtual,sealed,override,static,readonly,extern,unsafe,volatile,async:warning + +#Style - parentheses +# Skipped because roslyn cannot separate +-*/ with << >> + +#Style - expression bodies +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_local_functions = true:silent + +#Style - expression preferences +dotnet_style_object_initializer = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_compound_assignment = true:warning + +#Style - null/type checks +dotnet_style_coalesce_expression = true:warning +dotnet_style_null_propagation = true:warning +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_throw_expression = true:silent +csharp_style_conditional_delegate_call = true:warning + +#Style - unused +dotnet_style_readonly_field = true:silent +dotnet_code_quality_unused_parameters = non_public:silent +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_unused_value_assignment_preference = discard_variable:warning + +#Style - variable declaration +csharp_style_inlined_variable_declaration = true:warning +csharp_style_deconstructed_variable_declaration = true:warning + +#Style - other C# 7.x features +dotnet_style_prefer_inferred_tuple_names = true:warning +csharp_prefer_simple_default_expression = true:warning +csharp_style_pattern_local_over_anonymous_function = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent + +#Style - C# 8 features +csharp_prefer_static_local_function = true:warning +csharp_prefer_simple_using_statement = true:silent +csharp_style_prefer_index_operator = true:warning +csharp_style_prefer_range_operator = true:warning +csharp_style_prefer_switch_expression = false:none + +#Supressing roslyn built-in analyzers +# Suppress: EC112 + +#Private method is unused +dotnet_diagnostic.IDE0051.severity = silent +#Private member is unused +dotnet_diagnostic.IDE0052.severity = silent + +#Rules for disposable +dotnet_diagnostic.IDE0067.severity = none +dotnet_diagnostic.IDE0068.severity = none +dotnet_diagnostic.IDE0069.severity = none + +#Disable operator overloads requiring alternate named methods +dotnet_diagnostic.CA2225.severity = none + +# Banned APIs +dotnet_diagnostic.RS0030.severity = error \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-empty/.gitignore b/Templates/Rulesets/ruleset-empty/.gitignore new file mode 100644 index 0000000000..940794e60f --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/.gitignore @@ -0,0 +1,288 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs diff --git a/Templates/Rulesets/ruleset-empty/.template.config/template.json b/Templates/Rulesets/ruleset-empty/.template.config/template.json new file mode 100644 index 0000000000..6bfe2e19dc --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/.template.config/template.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "ppy Pty Ltd", + "classifications": [ + "Console" + ], + "name": "osu! ruleset", + "identity": "ppy.osu.Game.Templates.Rulesets", + "shortName": "ruleset", + "tags": { + "language": "C#", + "type": "project" + }, + "sourceName": "EmptyFreeform", + "preferNameDirectory": true +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/.vscode/launch.json b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/.vscode/launch.json new file mode 100644 index 0000000000..fd03878699 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "VisualTests (Debug)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "${workspaceRoot}/bin/Debug/net5.0/osu.Game.Rulesets.EmptyFreeformRuleset.Tests.dll" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build (Debug)", + "env": {}, + "console": "internalConsole" + }, + { + "name": "VisualTests (Release)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "${workspaceRoot}/bin/Release/net5.0/osu.Game.Rulesets.EmptyFreeformRuleset.Tests.dll" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build (Release)", + "env": {}, + "console": "internalConsole" + } + ] +} \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/.vscode/tasks.json b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/.vscode/tasks.json new file mode 100644 index 0000000000..509df6a510 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/.vscode/tasks.json @@ -0,0 +1,47 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Build (Debug)", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Rulesets.EmptyFreeformRuleset.Tests.csproj", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "Build (Release)", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Rulesets.EmptyFreeformRuleset.Tests.csproj", + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "Restore", + "type": "shell", + "command": "dotnet", + "args": [ + "restore" + ], + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs new file mode 100644 index 0000000000..9c512a01ea --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuGame.cs @@ -0,0 +1,32 @@ +// 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.Shapes; +using osu.Framework.Platform; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.EmptyFreeform.Tests +{ + public class TestSceneOsuGame : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(GameHost host, OsuGameBase gameBase) + { + OsuGame game = new OsuGame(); + game.SetHost(host); + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + game + }; + } + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuPlayer.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuPlayer.cs new file mode 100644 index 0000000000..0f2ddf82a5 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/TestSceneOsuPlayer.cs @@ -0,0 +1,14 @@ +// 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.Game.Tests.Visual; + +namespace osu.Game.Rulesets.EmptyFreeform.Tests +{ + [TestFixture] + public class TestSceneOsuPlayer : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new EmptyFreeformRuleset(); + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/VisualTestRunner.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/VisualTestRunner.cs new file mode 100644 index 0000000000..4f810ce17f --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/VisualTestRunner.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework; +using osu.Framework.Platform; +using osu.Game.Tests; + +namespace osu.Game.Rulesets.EmptyFreeform.Tests +{ + public static class VisualTestRunner + { + [STAThread] + public static int Main(string[] args) + { + using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) + { + host.Run(new OsuTestBrowser()); + return 0; + } + } + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj new file mode 100644 index 0000000000..5eb5efa54c --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -0,0 +1,26 @@ + + + osu.Game.Rulesets.EmptyFreeform.Tests.VisualTestRunner + + + + + + false + + + + + + + + + + + + + WinExe + net5.0 + osu.Game.Rulesets.EmptyFreeform.Tests + + \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.sln b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.sln new file mode 100644 index 0000000000..706df08472 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.sln @@ -0,0 +1,96 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29123.88 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.EmptyFreeform", "osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj", "{5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyFreeform.Tests", "osu.Game.Rulesets.EmptyFreeform.Tests\osu.Game.Rulesets.EmptyFreeform.Tests.csproj", "{B4577C85-CB83-462A-BCE3-22FFEB16311D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + VisualTests|Any CPU = VisualTests|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Release|Any CPU.Build.0 = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.VisualTests|Any CPU.ActiveCfg = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.VisualTests|Any CPU.Build.0 = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Release|Any CPU.Build.0 = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.VisualTests|Any CPU.ActiveCfg = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.VisualTests|Any CPU.Build.0 = Debug|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {671B0BEC-2403-45B0-9357-2C97CC517668} + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + Policies = $0 + $0.TextStylePolicy = $1 + $1.EolMarker = Windows + $1.inheritsSet = VisualStudio + $1.inheritsScope = text/plain + $1.scope = text/x-csharp + $0.CSharpFormattingPolicy = $2 + $2.IndentSwitchSection = True + $2.NewLinesForBracesInProperties = True + $2.NewLinesForBracesInAccessors = True + $2.NewLinesForBracesInAnonymousMethods = True + $2.NewLinesForBracesInControlBlocks = True + $2.NewLinesForBracesInAnonymousTypes = True + $2.NewLinesForBracesInObjectCollectionArrayInitializers = True + $2.NewLinesForBracesInLambdaExpressionBody = True + $2.NewLineForElse = True + $2.NewLineForCatch = True + $2.NewLineForFinally = True + $2.NewLineForMembersInObjectInit = True + $2.NewLineForMembersInAnonymousTypes = True + $2.NewLineForClausesInQuery = True + $2.SpacingAfterMethodDeclarationName = False + $2.SpaceAfterMethodCallName = False + $2.SpaceBeforeOpenSquareBracket = False + $2.inheritsSet = Mono + $2.inheritsScope = text/x-csharp + $2.scope = text/x-csharp + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + Policies = $0 + $0.TextStylePolicy = $1 + $1.EolMarker = Windows + $1.inheritsSet = VisualStudio + $1.inheritsScope = text/plain + $1.scope = text/x-csharp + $0.CSharpFormattingPolicy = $2 + $2.IndentSwitchSection = True + $2.NewLinesForBracesInProperties = True + $2.NewLinesForBracesInAccessors = True + $2.NewLinesForBracesInAnonymousMethods = True + $2.NewLinesForBracesInControlBlocks = True + $2.NewLinesForBracesInAnonymousTypes = True + $2.NewLinesForBracesInObjectCollectionArrayInitializers = True + $2.NewLinesForBracesInLambdaExpressionBody = True + $2.NewLineForElse = True + $2.NewLineForCatch = True + $2.NewLineForFinally = True + $2.NewLineForMembersInObjectInit = True + $2.NewLineForMembersInAnonymousTypes = True + $2.NewLineForClausesInQuery = True + $2.SpacingAfterMethodDeclarationName = False + $2.SpaceAfterMethodCallName = False + $2.SpaceBeforeOpenSquareBracket = False + $2.inheritsSet = Mono + $2.inheritsScope = text/x-csharp + $2.scope = text/x-csharp + EndGlobalSection +EndGlobal diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.sln.DotSettings b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.sln.DotSettings new file mode 100644 index 0000000000..aa8f8739c1 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.sln.DotSettings @@ -0,0 +1,934 @@ + + True + True + True + True + ExplicitlyExcluded + ExplicitlyExcluded + SOLUTION + WARNING + WARNING + WARNING + HINT + HINT + WARNING + WARNING + True + WARNING + WARNING + HINT + DO_NOT_SHOW + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + SUGGESTION + HINT + HINT + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + DO_NOT_SHOW + WARNING + HINT + DO_NOT_SHOW + HINT + HINT + ERROR + WARNING + HINT + HINT + HINT + WARNING + WARNING + HINT + DO_NOT_SHOW + HINT + HINT + HINT + HINT + WARNING + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + HINT + HINT + DO_NOT_SHOW + HINT + HINT + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + HINT + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + ERROR + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + WARNING + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + + True + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + HINT + WARNING + WARNING + <?xml version="1.0" encoding="utf-16"?><Profile name="Code Cleanup (peppy)"><CSArrangeThisQualifier>True</CSArrangeThisQualifier><CSUseVar><BehavourStyle>CAN_CHANGE_TO_EXPLICIT</BehavourStyle><LocalVariableStyle>ALWAYS_EXPLICIT</LocalVariableStyle><ForeachVariableStyle>ALWAYS_EXPLICIT</ForeachVariableStyle></CSUseVar><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSUpdateFileHeader>True</CSUpdateFileHeader><CSCodeStyleAttributes ArrangeTypeAccessModifier="False" ArrangeTypeMemberAccessModifier="False" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="False" ArrangeBraces="False" ArrangeAttributes="False" ArrangeArgumentsStyle="False" /><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CSArrangeQualifiers>True</CSArrangeQualifiers></Profile> + Code Cleanup (peppy) + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + Explicit + ExpressionBody + BlockBody + True + NEXT_LINE + True + True + True + True + True + True + True + True + NEXT_LINE + 1 + 1 + NEXT_LINE + MULTILINE + True + True + True + True + NEXT_LINE + 1 + 1 + True + NEXT_LINE + NEVER + NEVER + True + False + True + NEVER + False + False + True + False + False + True + True + False + False + CHOP_IF_LONG + True + 200 + CHOP_IF_LONG + False + False + AABB + API + BPM + GC + GL + GLSL + HID + HTML + HUD + ID + IL + IOS + IP + IPC + JIT + LTRB + MD5 + NS + OS + PM + RGB + RNG + SHA + SRGB + TK + SS + PP + GMT + QAT + BNG + UI + False + HINT + <?xml version="1.0" encoding="utf-16"?> +<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"> + <TypePattern DisplayName="COM interfaces or structs"> + <TypePattern.Match> + <Or> + <And> + <Kind Is="Interface" /> + <Or> + <HasAttribute Name="System.Runtime.InteropServices.InterfaceTypeAttribute" /> + <HasAttribute Name="System.Runtime.InteropServices.ComImport" /> + </Or> + </And> + <Kind Is="Struct" /> + </Or> + </TypePattern.Match> + </TypePattern> + <TypePattern DisplayName="NUnit Test Fixtures" RemoveRegions="All"> + <TypePattern.Match> + <And> + <Kind Is="Class" /> + <HasAttribute Name="NUnit.Framework.TestFixtureAttribute" Inherited="True" /> + </And> + </TypePattern.Match> + <Entry DisplayName="Setup/Teardown Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <Or> + <HasAttribute Name="NUnit.Framework.SetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.TearDownAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureSetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureTearDownAttribute" Inherited="True" /> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="All other members" /> + <Entry Priority="100" DisplayName="Test Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <HasAttribute Name="NUnit.Framework.TestAttribute" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Name /> + </Entry.SortBy> + </Entry> + </TypePattern> + <TypePattern DisplayName="Default Pattern"> + <Group DisplayName="Fields/Properties"> + <Group DisplayName="Public Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Public Properties"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Internal Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Internal Properties"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Protected Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Protected Properties"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Private Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Private Properties"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Constructor/Destructor"> + <Entry DisplayName="Ctor"> + <Entry.Match> + <Kind Is="Constructor" /> + </Entry.Match> + </Entry> + <Region Name="Disposal"> + <Entry DisplayName="Dtor"> + <Entry.Match> + <Kind Is="Destructor" /> + </Entry.Match> + </Entry> + <Entry DisplayName="Dispose()"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Method" /> + <Name Is="Dispose" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Dispose(true)"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Or> + <Virtual /> + <Override /> + </Or> + <Kind Is="Method" /> + <Name Is="Dispose" /> + </And> + </Entry.Match> + </Entry> + </Region> + </Group> + <Group DisplayName="Methods"> + <Group DisplayName="Public"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Internal"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Protected"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Private"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + </Group> + </TypePattern> +</Patterns> + Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +See the LICENCE file in the repository root for full licence text. + + <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> + <Policy Inspect="False" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private methods"><ElementKinds><Kind Name="ASYNC_METHOD" /><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public methods"><ElementKinds><Kind Name="ASYNC_METHOD" /><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="T" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + + True + True + True + True + True + True + True + True + True + True + True + TestFolder + True + True + o!f – Object Initializer: Anchor&Origin + True + constant("Centre") + 0 + True + True + 2.0 + InCSharpFile + ofao + True + Anchor = Anchor.$anchor$, +Origin = Anchor.$anchor$, + True + True + o!f – InternalChildren = [] + True + True + 2.0 + InCSharpFile + ofic + True + InternalChildren = new Drawable[] +{ + $END$ +}; + True + True + o!f – new GridContainer { .. } + True + True + 2.0 + InCSharpFile + ofgc + True + new GridContainer +{ + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { $END$ }, + new Drawable[] { } + } +}; + True + True + o!f – new FillFlowContainer { .. } + True + True + 2.0 + InCSharpFile + offf + True + new FillFlowContainer +{ + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + $END$ + } +}, + True + True + o!f – new Container { .. } + True + True + 2.0 + InCSharpFile + ofcont + True + new Container +{ + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + $END$ + } +}, + True + True + o!f – BackgroundDependencyLoader load() + True + True + 2.0 + InCSharpFile + ofbdl + True + [BackgroundDependencyLoader] +private void load() +{ + $END$ +} + True + True + o!f – new Box { .. } + True + True + 2.0 + InCSharpFile + ofbox + True + new Box +{ + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, +}, + True + True + o!f – Children = [] + True + True + 2.0 + InCSharpFile + ofc + True + Children = new Drawable[] +{ + $END$ +}; + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Beatmaps/EmptyFreeformBeatmapConverter.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Beatmaps/EmptyFreeformBeatmapConverter.cs new file mode 100644 index 0000000000..a441438d80 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Beatmaps/EmptyFreeformBeatmapConverter.cs @@ -0,0 +1,35 @@ +// 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.Threading; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.EmptyFreeform.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.EmptyFreeform.Beatmaps +{ + public class EmptyFreeformBeatmapConverter : BeatmapConverter + { + public EmptyFreeformBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + : base(beatmap, ruleset) + { + } + + // todo: Check for conversion types that should be supported (ie. Beatmap.HitObjects.Any(h => h is IHasXPosition)) + // https://github.com/ppy/osu/tree/master/osu.Game/Rulesets/Objects/Types + public override bool CanConvert() => true; + + protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) + { + yield return new EmptyFreeformHitObject + { + Samples = original.Samples, + StartTime = original.StartTime, + Position = (original as IHasPosition)?.Position ?? Vector2.Zero, + }; + } + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs new file mode 100644 index 0000000000..59a68245a6 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.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.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.EmptyFreeform +{ + public class EmptyFreeformDifficultyCalculator : DifficultyCalculator + { + public EmptyFreeformDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) + : base(ruleset, beatmap) + { + } + + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + { + return new DifficultyAttributes(mods, skills, 0); + } + + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformInputManager.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformInputManager.cs new file mode 100644 index 0000000000..b292a28c0d --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformInputManager.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.EmptyFreeform +{ + public class EmptyFreeformInputManager : RulesetInputManager + { + public EmptyFreeformInputManager(RulesetInfo ruleset) + : base(ruleset, 0, SimultaneousBindingMode.Unique) + { + } + } + + public enum EmptyFreeformAction + { + [Description("Button 1")] + Button1, + + [Description("Button 2")] + Button2, + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs new file mode 100644 index 0000000000..96675e3e99 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformRuleset.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.EmptyFreeform.Beatmaps; +using osu.Game.Rulesets.EmptyFreeform.Mods; +using osu.Game.Rulesets.EmptyFreeform.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.EmptyFreeform +{ + public class EmptyFreeformRuleset : Ruleset + { + public override string Description => "a very emptyfreeformruleset ruleset"; + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => + new DrawableEmptyFreeformRuleset(this, beatmap, mods); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => + new EmptyFreeformBeatmapConverter(beatmap, this); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => + new EmptyFreeformDifficultyCalculator(this, beatmap); + + public override IEnumerable GetModsFor(ModType type) + { + switch (type) + { + case ModType.Automation: + return new[] { new EmptyFreeformModAutoplay() }; + + default: + return new Mod[] { null }; + } + } + + public override string ShortName => "emptyfreeformruleset"; + + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] + { + new KeyBinding(InputKey.Z, EmptyFreeformAction.Button1), + new KeyBinding(InputKey.X, EmptyFreeformAction.Button2), + }; + + public override Drawable CreateIcon() => new Icon(ShortName[0]); + + public class Icon : CompositeDrawable + { + public Icon(char c) + { + InternalChildren = new Drawable[] + { + new Circle + { + Size = new Vector2(20), + Colour = Color4.White, + }, + new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = c.ToString(), + Font = OsuFont.Default.With(size: 18) + } + }; + } + } + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs new file mode 100644 index 0000000000..d5c1e9bd15 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.EmptyFreeform.Objects; +using osu.Game.Rulesets.EmptyFreeform.Replays; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Rulesets.EmptyFreeform.Mods +{ + public class EmptyFreeformModAutoplay : ModAutoplay + { + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score + { + ScoreInfo = new ScoreInfo + { + User = new User { Username = "sample" }, + }, + Replay = new EmptyFreeformAutoGenerator(beatmap).Generate(), + }; + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/Drawables/DrawableEmptyFreeformHitObject.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/Drawables/DrawableEmptyFreeformHitObject.cs new file mode 100644 index 0000000000..0f38e9fdf8 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/Drawables/DrawableEmptyFreeformHitObject.cs @@ -0,0 +1,49 @@ +// 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.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.EmptyFreeform.Objects.Drawables +{ + public class DrawableEmptyFreeformHitObject : DrawableHitObject + { + public DrawableEmptyFreeformHitObject(EmptyFreeformHitObject hitObject) + : base(hitObject) + { + Size = new Vector2(40); + Origin = Anchor.Centre; + + Position = hitObject.Position; + + // todo: add visuals. + } + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (timeOffset >= 0) + // todo: implement judgement logic + ApplyResult(r => r.Type = HitResult.Perfect); + } + + protected override void UpdateHitStateTransforms(ArmedState state) + { + const double duration = 1000; + + switch (state) + { + case ArmedState.Hit: + this.FadeOut(duration, Easing.OutQuint).Expire(); + break; + + case ArmedState.Miss: + this.FadeColour(Color4.Red, duration); + this.FadeOut(duration, Easing.InQuint).Expire(); + break; + } + } + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs new file mode 100644 index 0000000000..9cd18d2d9f --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Objects/EmptyFreeformHitObject.cs @@ -0,0 +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 osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.EmptyFreeform.Objects +{ + public class EmptyFreeformHitObject : HitObject, IHasPosition + { + public override Judgement CreateJudgement() => new Judgement(); + + public Vector2 Position { get; set; } + + public float X => Position.X; + public float Y => Position.Y; + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformAutoGenerator.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformAutoGenerator.cs new file mode 100644 index 0000000000..62f394d1ce --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformAutoGenerator.cs @@ -0,0 +1,34 @@ +// 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.EmptyFreeform.Objects; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.EmptyFreeform.Replays +{ + public class EmptyFreeformAutoGenerator : AutoGenerator + { + public new Beatmap Beatmap => (Beatmap)base.Beatmap; + + public EmptyFreeformAutoGenerator(IBeatmap beatmap) + : base(beatmap) + { + } + + protected override void GenerateFrames() + { + Frames.Add(new EmptyFreeformReplayFrame()); + + foreach (EmptyFreeformHitObject hitObject in Beatmap.HitObjects) + { + Frames.Add(new EmptyFreeformReplayFrame + { + Time = hitObject.StartTime, + Position = hitObject.Position, + // todo: add required inputs and extra frames. + }); + } + } + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformFramedReplayInputHandler.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformFramedReplayInputHandler.cs new file mode 100644 index 0000000000..cc4483de31 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformFramedReplayInputHandler.cs @@ -0,0 +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.Collections.Generic; +using System.Linq; +using osu.Framework.Input.StateChanges; +using osu.Framework.Utils; +using osu.Game.Replays; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.EmptyFreeform.Replays +{ + public class EmptyFreeformFramedReplayInputHandler : FramedReplayInputHandler + { + public EmptyFreeformFramedReplayInputHandler(Replay replay) + : base(replay) + { + } + + protected override bool IsImportant(EmptyFreeformReplayFrame frame) => frame.Actions.Any(); + + public override void CollectPendingInputs(List inputs) + { + var position = Interpolation.ValueAt(CurrentTime, StartFrame.Position, EndFrame.Position, StartFrame.Time, EndFrame.Time); + + inputs.Add(new MousePositionAbsoluteInput + { + Position = GamefieldToScreenSpace(position), + }); + inputs.Add(new ReplayState + { + PressedActions = CurrentFrame?.Actions ?? new List(), + }); + } + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs new file mode 100644 index 0000000000..c84101ca70 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Replays/EmptyFreeformReplayFrame.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Replays; +using osuTK; + +namespace osu.Game.Rulesets.EmptyFreeform.Replays +{ + public class EmptyFreeformReplayFrame : ReplayFrame + { + public List Actions = new List(); + public Vector2 Position; + + public EmptyFreeformReplayFrame(EmptyFreeformAction? button = null) + { + if (button.HasValue) + Actions.Add(button.Value); + } + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/DrawableEmptyFreeformRuleset.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/DrawableEmptyFreeformRuleset.cs new file mode 100644 index 0000000000..290f35f516 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/DrawableEmptyFreeformRuleset.cs @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Input; +using osu.Game.Beatmaps; +using osu.Game.Input.Handlers; +using osu.Game.Replays; +using osu.Game.Rulesets.EmptyFreeform.Objects; +using osu.Game.Rulesets.EmptyFreeform.Objects.Drawables; +using osu.Game.Rulesets.EmptyFreeform.Replays; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.EmptyFreeform.UI +{ + [Cached] + public class DrawableEmptyFreeformRuleset : DrawableRuleset + { + public DrawableEmptyFreeformRuleset(EmptyFreeformRuleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + : base(ruleset, beatmap, mods) + { + } + + protected override Playfield CreatePlayfield() => new EmptyFreeformPlayfield(); + + protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new EmptyFreeformFramedReplayInputHandler(replay); + + public override DrawableHitObject CreateDrawableRepresentation(EmptyFreeformHitObject h) => new DrawableEmptyFreeformHitObject(h); + + protected override PassThroughInputManager CreateInputManager() => new EmptyFreeformInputManager(Ruleset?.RulesetInfo); + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/EmptyFreeformPlayfield.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/EmptyFreeformPlayfield.cs new file mode 100644 index 0000000000..9df5935c45 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/UI/EmptyFreeformPlayfield.cs @@ -0,0 +1,22 @@ +// 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.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.EmptyFreeform.UI +{ + [Cached] + public class EmptyFreeformPlayfield : Playfield + { + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + HitObjectContainer, + }); + } + } +} diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj new file mode 100644 index 0000000000..cfe2bd1cb2 --- /dev/null +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/osu.Game.Rulesets.EmptyFreeform.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + osu.Game.Rulesets.Sample + Library + AnyCPU + osu.Game.Rulesets.EmptyFreeform + + + + + + + + \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-example/.editorconfig b/Templates/Rulesets/ruleset-example/.editorconfig new file mode 100644 index 0000000000..f3badda9b3 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/.editorconfig @@ -0,0 +1,200 @@ +# EditorConfig is awesome: http://editorconfig.org +root = true + +[*.cs] +end_of_line = crlf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +#Roslyn naming styles + +#PascalCase for public and protected members +dotnet_naming_style.pascalcase.capitalization = pascal_case +dotnet_naming_symbols.public_members.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_members.applicable_kinds = property,method,field,event +dotnet_naming_rule.public_members_pascalcase.severity = error +dotnet_naming_rule.public_members_pascalcase.symbols = public_members +dotnet_naming_rule.public_members_pascalcase.style = pascalcase + +#camelCase for private members +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_symbols.private_members.applicable_accessibilities = private +dotnet_naming_symbols.private_members.applicable_kinds = property,method,field,event +dotnet_naming_rule.private_members_camelcase.severity = warning +dotnet_naming_rule.private_members_camelcase.symbols = private_members +dotnet_naming_rule.private_members_camelcase.style = camelcase + +dotnet_naming_symbols.local_function.applicable_kinds = local_function +dotnet_naming_rule.local_function_camelcase.severity = warning +dotnet_naming_rule.local_function_camelcase.symbols = local_function +dotnet_naming_rule.local_function_camelcase.style = camelcase + +#all_lower for private and local constants/static readonlys +dotnet_naming_style.all_lower.capitalization = all_lower +dotnet_naming_style.all_lower.word_separator = _ + +dotnet_naming_symbols.private_constants.applicable_accessibilities = private +dotnet_naming_symbols.private_constants.required_modifiers = const +dotnet_naming_symbols.private_constants.applicable_kinds = field +dotnet_naming_rule.private_const_all_lower.severity = warning +dotnet_naming_rule.private_const_all_lower.symbols = private_constants +dotnet_naming_rule.private_const_all_lower.style = all_lower + +dotnet_naming_symbols.private_static_readonly.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly.required_modifiers = static,readonly +dotnet_naming_symbols.private_static_readonly.applicable_kinds = field +dotnet_naming_rule.private_static_readonly_all_lower.severity = warning +dotnet_naming_rule.private_static_readonly_all_lower.symbols = private_static_readonly +dotnet_naming_rule.private_static_readonly_all_lower.style = all_lower + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.required_modifiers = const +dotnet_naming_rule.local_const_all_lower.severity = warning +dotnet_naming_rule.local_const_all_lower.symbols = local_constants +dotnet_naming_rule.local_const_all_lower.style = all_lower + +#ALL_UPPER for non private constants/static readonlys +dotnet_naming_style.all_upper.capitalization = all_upper +dotnet_naming_style.all_upper.word_separator = _ + +dotnet_naming_symbols.public_constants.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_constants.required_modifiers = const +dotnet_naming_symbols.public_constants.applicable_kinds = field +dotnet_naming_rule.public_const_all_upper.severity = warning +dotnet_naming_rule.public_const_all_upper.symbols = public_constants +dotnet_naming_rule.public_const_all_upper.style = all_upper + +dotnet_naming_symbols.public_static_readonly.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_static_readonly.required_modifiers = static,readonly +dotnet_naming_symbols.public_static_readonly.applicable_kinds = field +dotnet_naming_rule.public_static_readonly_all_upper.severity = warning +dotnet_naming_rule.public_static_readonly_all_upper.symbols = public_static_readonly +dotnet_naming_rule.public_static_readonly_all_upper.style = all_upper + +#Roslyn formating options + +#Formatting - indentation options +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +#Formatting - new line options +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_open_brace = all +#csharp_new_line_before_members_in_anonymous_types = true +#csharp_new_line_before_members_in_object_initializers = true # Currently no effect in VS/dotnet format (16.4), and makes Rider confusing +csharp_new_line_between_query_expression_clauses = true + +#Formatting - organize using options +dotnet_sort_system_directives_first = true + +#Formatting - spacing options +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false + +#Formatting - wrapping options +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#Roslyn language styles + +#Style - this. qualification +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +#Style - type names +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_for_built_in_types = true:none +csharp_style_var_elsewhere = true:silent + +#Style - modifiers +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +csharp_preferred_modifier_order = public,private,protected,internal,new,abstract,virtual,sealed,override,static,readonly,extern,unsafe,volatile,async:warning + +#Style - parentheses +# Skipped because roslyn cannot separate +-*/ with << >> + +#Style - expression bodies +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_local_functions = true:silent + +#Style - expression preferences +dotnet_style_object_initializer = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_compound_assignment = true:warning + +#Style - null/type checks +dotnet_style_coalesce_expression = true:warning +dotnet_style_null_propagation = true:warning +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_throw_expression = true:silent +csharp_style_conditional_delegate_call = true:warning + +#Style - unused +dotnet_style_readonly_field = true:silent +dotnet_code_quality_unused_parameters = non_public:silent +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_unused_value_assignment_preference = discard_variable:warning + +#Style - variable declaration +csharp_style_inlined_variable_declaration = true:warning +csharp_style_deconstructed_variable_declaration = true:warning + +#Style - other C# 7.x features +dotnet_style_prefer_inferred_tuple_names = true:warning +csharp_prefer_simple_default_expression = true:warning +csharp_style_pattern_local_over_anonymous_function = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent + +#Style - C# 8 features +csharp_prefer_static_local_function = true:warning +csharp_prefer_simple_using_statement = true:silent +csharp_style_prefer_index_operator = true:warning +csharp_style_prefer_range_operator = true:warning +csharp_style_prefer_switch_expression = false:none + +#Supressing roslyn built-in analyzers +# Suppress: EC112 + +#Private method is unused +dotnet_diagnostic.IDE0051.severity = silent +#Private member is unused +dotnet_diagnostic.IDE0052.severity = silent + +#Rules for disposable +dotnet_diagnostic.IDE0067.severity = none +dotnet_diagnostic.IDE0068.severity = none +dotnet_diagnostic.IDE0069.severity = none + +#Disable operator overloads requiring alternate named methods +dotnet_diagnostic.CA2225.severity = none + +# Banned APIs +dotnet_diagnostic.RS0030.severity = error \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-example/.gitignore b/Templates/Rulesets/ruleset-example/.gitignore new file mode 100644 index 0000000000..940794e60f --- /dev/null +++ b/Templates/Rulesets/ruleset-example/.gitignore @@ -0,0 +1,288 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs diff --git a/Templates/Rulesets/ruleset-example/.template.config/template.json b/Templates/Rulesets/ruleset-example/.template.config/template.json new file mode 100644 index 0000000000..5d2f6f1ebd --- /dev/null +++ b/Templates/Rulesets/ruleset-example/.template.config/template.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "ppy Pty Ltd", + "classifications": [ + "Console" + ], + "name": "osu! ruleset (pippidon example)", + "identity": "ppy.osu.Game.Templates.Rulesets.Pippidon", + "shortName": "ruleset-example", + "tags": { + "language": "C#", + "type": "project" + }, + "sourceName": "Pippidon", + "preferNameDirectory": true +} + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/launch.json b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/launch.json new file mode 100644 index 0000000000..bd9db14259 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "VisualTests (Debug)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "${workspaceRoot}/bin/Debug/net5.0/osu.Game.Rulesets.Pippidon.Tests.dll" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build (Debug)", + "env": {}, + "console": "internalConsole" + }, + { + "name": "VisualTests (Release)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "${workspaceRoot}/bin/Release/net5.0/osu.Game.Rulesets.Pippidon.Tests.dll" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build (Release)", + "env": {}, + "console": "internalConsole" + } + ] +} \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/tasks.json b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/tasks.json new file mode 100644 index 0000000000..0ee07c1036 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/tasks.json @@ -0,0 +1,47 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Build (Debug)", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Rulesets.Pippidon.Tests.csproj", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "Build (Release)", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Rulesets.Pippidon.Tests.csproj", + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "Restore", + "type": "shell", + "command": "dotnet", + "args": [ + "restore" + ], + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs new file mode 100644 index 0000000000..270d906b01 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs @@ -0,0 +1,32 @@ +// 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.Shapes; +using osu.Framework.Platform; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Pippidon.Tests +{ + public class TestSceneOsuGame : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(GameHost host, OsuGameBase gameBase) + { + OsuGame game = new OsuGame(); + game.SetHost(host); + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + game + }; + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs new file mode 100644 index 0000000000..f00528900c --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs @@ -0,0 +1,14 @@ +// 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.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Pippidon.Tests +{ + [TestFixture] + public class TestSceneOsuPlayer : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new PippidonRuleset(); + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/VisualTestRunner.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/VisualTestRunner.cs new file mode 100644 index 0000000000..fd6bd9b714 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/VisualTestRunner.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework; +using osu.Framework.Platform; +using osu.Game.Tests; + +namespace osu.Game.Rulesets.Pippidon.Tests +{ + public static class VisualTestRunner + { + [STAThread] + public static int Main(string[] args) + { + using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) + { + host.Run(new OsuTestBrowser()); + return 0; + } + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj new file mode 100644 index 0000000000..d7c116411a --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -0,0 +1,26 @@ + + + osu.Game.Rulesets.Pippidon.Tests.VisualTestRunner + + + + + + false + + + + + + + + + + + + + WinExe + net5.0 + osu.Game.Rulesets.Pippidon.Tests + + \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.sln b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.sln new file mode 100644 index 0000000000..bccffcd7ff --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.sln @@ -0,0 +1,96 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29123.88 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.Pippidon", "osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj", "{5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon.Tests", "osu.Game.Rulesets.Pippidon.Tests\osu.Game.Rulesets.Pippidon.Tests.csproj", "{B4577C85-CB83-462A-BCE3-22FFEB16311D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + VisualTests|Any CPU = VisualTests|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Release|Any CPU.Build.0 = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.VisualTests|Any CPU.ActiveCfg = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.VisualTests|Any CPU.Build.0 = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Release|Any CPU.Build.0 = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.VisualTests|Any CPU.ActiveCfg = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.VisualTests|Any CPU.Build.0 = Debug|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {671B0BEC-2403-45B0-9357-2C97CC517668} + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + Policies = $0 + $0.TextStylePolicy = $1 + $1.EolMarker = Windows + $1.inheritsSet = VisualStudio + $1.inheritsScope = text/plain + $1.scope = text/x-csharp + $0.CSharpFormattingPolicy = $2 + $2.IndentSwitchSection = True + $2.NewLinesForBracesInProperties = True + $2.NewLinesForBracesInAccessors = True + $2.NewLinesForBracesInAnonymousMethods = True + $2.NewLinesForBracesInControlBlocks = True + $2.NewLinesForBracesInAnonymousTypes = True + $2.NewLinesForBracesInObjectCollectionArrayInitializers = True + $2.NewLinesForBracesInLambdaExpressionBody = True + $2.NewLineForElse = True + $2.NewLineForCatch = True + $2.NewLineForFinally = True + $2.NewLineForMembersInObjectInit = True + $2.NewLineForMembersInAnonymousTypes = True + $2.NewLineForClausesInQuery = True + $2.SpacingAfterMethodDeclarationName = False + $2.SpaceAfterMethodCallName = False + $2.SpaceBeforeOpenSquareBracket = False + $2.inheritsSet = Mono + $2.inheritsScope = text/x-csharp + $2.scope = text/x-csharp + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + Policies = $0 + $0.TextStylePolicy = $1 + $1.EolMarker = Windows + $1.inheritsSet = VisualStudio + $1.inheritsScope = text/plain + $1.scope = text/x-csharp + $0.CSharpFormattingPolicy = $2 + $2.IndentSwitchSection = True + $2.NewLinesForBracesInProperties = True + $2.NewLinesForBracesInAccessors = True + $2.NewLinesForBracesInAnonymousMethods = True + $2.NewLinesForBracesInControlBlocks = True + $2.NewLinesForBracesInAnonymousTypes = True + $2.NewLinesForBracesInObjectCollectionArrayInitializers = True + $2.NewLinesForBracesInLambdaExpressionBody = True + $2.NewLineForElse = True + $2.NewLineForCatch = True + $2.NewLineForFinally = True + $2.NewLineForMembersInObjectInit = True + $2.NewLineForMembersInAnonymousTypes = True + $2.NewLineForClausesInQuery = True + $2.SpacingAfterMethodDeclarationName = False + $2.SpaceAfterMethodCallName = False + $2.SpaceBeforeOpenSquareBracket = False + $2.inheritsSet = Mono + $2.inheritsScope = text/x-csharp + $2.scope = text/x-csharp + EndGlobalSection +EndGlobal diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.sln.DotSettings b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.sln.DotSettings new file mode 100644 index 0000000000..aa8f8739c1 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.sln.DotSettings @@ -0,0 +1,934 @@ + + True + True + True + True + ExplicitlyExcluded + ExplicitlyExcluded + SOLUTION + WARNING + WARNING + WARNING + HINT + HINT + WARNING + WARNING + True + WARNING + WARNING + HINT + DO_NOT_SHOW + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + SUGGESTION + HINT + HINT + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + DO_NOT_SHOW + WARNING + HINT + DO_NOT_SHOW + HINT + HINT + ERROR + WARNING + HINT + HINT + HINT + WARNING + WARNING + HINT + DO_NOT_SHOW + HINT + HINT + HINT + HINT + WARNING + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + HINT + HINT + DO_NOT_SHOW + HINT + HINT + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + HINT + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + ERROR + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + WARNING + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + + True + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + HINT + WARNING + WARNING + <?xml version="1.0" encoding="utf-16"?><Profile name="Code Cleanup (peppy)"><CSArrangeThisQualifier>True</CSArrangeThisQualifier><CSUseVar><BehavourStyle>CAN_CHANGE_TO_EXPLICIT</BehavourStyle><LocalVariableStyle>ALWAYS_EXPLICIT</LocalVariableStyle><ForeachVariableStyle>ALWAYS_EXPLICIT</ForeachVariableStyle></CSUseVar><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSUpdateFileHeader>True</CSUpdateFileHeader><CSCodeStyleAttributes ArrangeTypeAccessModifier="False" ArrangeTypeMemberAccessModifier="False" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="False" ArrangeBraces="False" ArrangeAttributes="False" ArrangeArgumentsStyle="False" /><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CSArrangeQualifiers>True</CSArrangeQualifiers></Profile> + Code Cleanup (peppy) + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + Explicit + ExpressionBody + BlockBody + True + NEXT_LINE + True + True + True + True + True + True + True + True + NEXT_LINE + 1 + 1 + NEXT_LINE + MULTILINE + True + True + True + True + NEXT_LINE + 1 + 1 + True + NEXT_LINE + NEVER + NEVER + True + False + True + NEVER + False + False + True + False + False + True + True + False + False + CHOP_IF_LONG + True + 200 + CHOP_IF_LONG + False + False + AABB + API + BPM + GC + GL + GLSL + HID + HTML + HUD + ID + IL + IOS + IP + IPC + JIT + LTRB + MD5 + NS + OS + PM + RGB + RNG + SHA + SRGB + TK + SS + PP + GMT + QAT + BNG + UI + False + HINT + <?xml version="1.0" encoding="utf-16"?> +<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"> + <TypePattern DisplayName="COM interfaces or structs"> + <TypePattern.Match> + <Or> + <And> + <Kind Is="Interface" /> + <Or> + <HasAttribute Name="System.Runtime.InteropServices.InterfaceTypeAttribute" /> + <HasAttribute Name="System.Runtime.InteropServices.ComImport" /> + </Or> + </And> + <Kind Is="Struct" /> + </Or> + </TypePattern.Match> + </TypePattern> + <TypePattern DisplayName="NUnit Test Fixtures" RemoveRegions="All"> + <TypePattern.Match> + <And> + <Kind Is="Class" /> + <HasAttribute Name="NUnit.Framework.TestFixtureAttribute" Inherited="True" /> + </And> + </TypePattern.Match> + <Entry DisplayName="Setup/Teardown Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <Or> + <HasAttribute Name="NUnit.Framework.SetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.TearDownAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureSetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureTearDownAttribute" Inherited="True" /> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="All other members" /> + <Entry Priority="100" DisplayName="Test Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <HasAttribute Name="NUnit.Framework.TestAttribute" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Name /> + </Entry.SortBy> + </Entry> + </TypePattern> + <TypePattern DisplayName="Default Pattern"> + <Group DisplayName="Fields/Properties"> + <Group DisplayName="Public Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Public Properties"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Internal Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Internal Properties"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Protected Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Protected Properties"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Private Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Private Properties"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Constructor/Destructor"> + <Entry DisplayName="Ctor"> + <Entry.Match> + <Kind Is="Constructor" /> + </Entry.Match> + </Entry> + <Region Name="Disposal"> + <Entry DisplayName="Dtor"> + <Entry.Match> + <Kind Is="Destructor" /> + </Entry.Match> + </Entry> + <Entry DisplayName="Dispose()"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Method" /> + <Name Is="Dispose" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Dispose(true)"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Or> + <Virtual /> + <Override /> + </Or> + <Kind Is="Method" /> + <Name Is="Dispose" /> + </And> + </Entry.Match> + </Entry> + </Region> + </Group> + <Group DisplayName="Methods"> + <Group DisplayName="Public"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Internal"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Protected"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Private"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + </Group> + </TypePattern> +</Patterns> + Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +See the LICENCE file in the repository root for full licence text. + + <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> + <Policy Inspect="False" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private methods"><ElementKinds><Kind Name="ASYNC_METHOD" /><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public methods"><ElementKinds><Kind Name="ASYNC_METHOD" /><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="T" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + + True + True + True + True + True + True + True + True + True + True + True + TestFolder + True + True + o!f – Object Initializer: Anchor&Origin + True + constant("Centre") + 0 + True + True + 2.0 + InCSharpFile + ofao + True + Anchor = Anchor.$anchor$, +Origin = Anchor.$anchor$, + True + True + o!f – InternalChildren = [] + True + True + 2.0 + InCSharpFile + ofic + True + InternalChildren = new Drawable[] +{ + $END$ +}; + True + True + o!f – new GridContainer { .. } + True + True + 2.0 + InCSharpFile + ofgc + True + new GridContainer +{ + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { $END$ }, + new Drawable[] { } + } +}; + True + True + o!f – new FillFlowContainer { .. } + True + True + 2.0 + InCSharpFile + offf + True + new FillFlowContainer +{ + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + $END$ + } +}, + True + True + o!f – new Container { .. } + True + True + 2.0 + InCSharpFile + ofcont + True + new Container +{ + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + $END$ + } +}, + True + True + o!f – BackgroundDependencyLoader load() + True + True + 2.0 + InCSharpFile + ofbdl + True + [BackgroundDependencyLoader] +private void load() +{ + $END$ +} + True + True + o!f – new Box { .. } + True + True + 2.0 + InCSharpFile + ofbox + True + new Box +{ + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, +}, + True + True + o!f – Children = [] + True + True + 2.0 + InCSharpFile + ofc + True + Children = new Drawable[] +{ + $END$ +}; + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs new file mode 100644 index 0000000000..a2a4784603 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Pippidon.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Pippidon.Beatmaps +{ + public class PippidonBeatmapConverter : BeatmapConverter + { + public PippidonBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + : base(beatmap, ruleset) + { + } + + public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasPosition); + + protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) + { + yield return new PippidonHitObject + { + Samples = original.Samples, + StartTime = original.StartTime, + Position = (original as IHasPosition)?.Position ?? Vector2.Zero, + }; + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs new file mode 100644 index 0000000000..8ea334c99c --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Pippidon.Objects; +using osu.Game.Rulesets.Pippidon.Replays; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Rulesets.Pippidon.Mods +{ + public class PippidonModAutoplay : ModAutoplay + { + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score + { + ScoreInfo = new ScoreInfo + { + User = new User { Username = "sample" }, + }, + Replay = new PippidonAutoGenerator(beatmap).Generate(), + }; + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs new file mode 100644 index 0000000000..399d6adda2 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Pippidon.Objects.Drawables +{ + public class DrawablePippidonHitObject : DrawableHitObject + { + private const double time_preempt = 600; + private const double time_fadein = 400; + + public override bool HandlePositionalInput => true; + + public DrawablePippidonHitObject(PippidonHitObject hitObject) + : base(hitObject) + { + Size = new Vector2(80); + + Origin = Anchor.Centre; + Position = hitObject.Position; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AddInternal(new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("coin"), + }); + } + + public override IEnumerable GetSamples() => new[] + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK) + }; + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (timeOffset >= 0) + ApplyResult(r => r.Type = IsHovered ? HitResult.Perfect : HitResult.Miss); + } + + protected override double InitialLifetimeOffset => time_preempt; + + protected override void UpdateInitialTransforms() => this.FadeInFromZero(time_fadein); + + protected override void UpdateHitStateTransforms(ArmedState state) + { + switch (state) + { + case ArmedState.Hit: + this.ScaleTo(5, 1500, Easing.OutQuint).FadeOut(1500, Easing.OutQuint).Expire(); + break; + + case ArmedState.Miss: + const double duration = 1000; + + this.ScaleTo(0.8f, duration, Easing.OutQuint); + this.MoveToOffset(new Vector2(0, 10), duration, Easing.In); + this.FadeColour(Color4.Red.Opacity(0.5f), duration / 2, Easing.OutQuint).Then().FadeOut(duration / 2, Easing.InQuint).Expire(); + break; + } + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs new file mode 100644 index 0000000000..0c22554e82 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs @@ -0,0 +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 osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Pippidon.Objects +{ + public class PippidonHitObject : HitObject, IHasPosition + { + public override Judgement CreateJudgement() => new Judgement(); + + public Vector2 Position { get; set; } + + public float X => Position.X; + public float Y => Position.Y; + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs new file mode 100644 index 0000000000..f6340f6c25 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.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.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Pippidon +{ + public class PippidonDifficultyCalculator : DifficultyCalculator + { + public PippidonDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) + : base(ruleset, beatmap) + { + } + + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + { + return new DifficultyAttributes(mods, skills, 0); + } + + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs new file mode 100644 index 0000000000..aa7fa3188b --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Pippidon +{ + public class PippidonInputManager : RulesetInputManager + { + public PippidonInputManager(RulesetInfo ruleset) + : base(ruleset, 0, SimultaneousBindingMode.Unique) + { + } + } + + public enum PippidonAction + { + [Description("Button 1")] + Button1, + + [Description("Button 2")] + Button2, + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs new file mode 100644 index 0000000000..89fed791cd --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Pippidon.Beatmaps; +using osu.Game.Rulesets.Pippidon.Mods; +using osu.Game.Rulesets.Pippidon.UI; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Pippidon +{ + public class PippidonRuleset : Ruleset + { + public override string Description => "gather the osu!coins"; + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => + new DrawablePippidonRuleset(this, beatmap, mods); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => + new PippidonBeatmapConverter(beatmap, this); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => + new PippidonDifficultyCalculator(this, beatmap); + + public override IEnumerable GetModsFor(ModType type) + { + switch (type) + { + case ModType.Automation: + return new[] { new PippidonModAutoplay() }; + + default: + return new Mod[] { null }; + } + } + + public override string ShortName => "pippidon"; + + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] + { + new KeyBinding(InputKey.Z, PippidonAction.Button1), + new KeyBinding(InputKey.X, PippidonAction.Button2), + }; + + public override Drawable CreateIcon() => new Sprite + { + Texture = new TextureStore(new TextureLoaderStore(CreateResourceStore()), false).Get("Textures/coin"), + }; + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonAutoGenerator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonAutoGenerator.cs new file mode 100644 index 0000000000..612288257d --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonAutoGenerator.cs @@ -0,0 +1,33 @@ +// 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.Pippidon.Objects; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.Pippidon.Replays +{ + public class PippidonAutoGenerator : AutoGenerator + { + public new Beatmap Beatmap => (Beatmap)base.Beatmap; + + public PippidonAutoGenerator(IBeatmap beatmap) + : base(beatmap) + { + } + + protected override void GenerateFrames() + { + Frames.Add(new PippidonReplayFrame()); + + foreach (PippidonHitObject hitObject in Beatmap.HitObjects) + { + Frames.Add(new PippidonReplayFrame + { + Time = hitObject.StartTime, + Position = hitObject.Position, + }); + } + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonFramedReplayInputHandler.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonFramedReplayInputHandler.cs new file mode 100644 index 0000000000..e005346e1e --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonFramedReplayInputHandler.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Input.StateChanges; +using osu.Framework.Utils; +using osu.Game.Replays; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.Pippidon.Replays +{ + public class PippidonFramedReplayInputHandler : FramedReplayInputHandler + { + public PippidonFramedReplayInputHandler(Replay replay) + : base(replay) + { + } + + protected override bool IsImportant(PippidonReplayFrame frame) => true; + + public override void CollectPendingInputs(List inputs) + { + var position = Interpolation.ValueAt(CurrentTime, StartFrame.Position, EndFrame.Position, StartFrame.Time, EndFrame.Time); + + inputs.Add(new MousePositionAbsoluteInput + { + Position = GamefieldToScreenSpace(position) + }); + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs new file mode 100644 index 0000000000..949ca160be --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Replays; +using osuTK; + +namespace osu.Game.Rulesets.Pippidon.Replays +{ + public class PippidonReplayFrame : ReplayFrame + { + public Vector2 Position; + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Resources/Samples/Gameplay/normal-hitnormal.mp3 b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Resources/Samples/Gameplay/normal-hitnormal.mp3 new file mode 100644 index 0000000000..90b13d1f73 Binary files /dev/null and b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Resources/Samples/Gameplay/normal-hitnormal.mp3 differ diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Resources/Textures/character.png b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Resources/Textures/character.png new file mode 100644 index 0000000000..e79d2528ec Binary files /dev/null and b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Resources/Textures/character.png differ diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Resources/Textures/coin.png b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Resources/Textures/coin.png new file mode 100644 index 0000000000..3cd89c6ce6 Binary files /dev/null and b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Resources/Textures/coin.png differ diff --git a/osu.Game/Rulesets/Replays/IAutoGenerator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Scoring/PippidonScoreProcessor.cs similarity index 55% rename from osu.Game/Rulesets/Replays/IAutoGenerator.cs rename to Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Scoring/PippidonScoreProcessor.cs index b1905e2b6f..1c4fe698c2 100644 --- a/osu.Game/Rulesets/Replays/IAutoGenerator.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Scoring/PippidonScoreProcessor.cs @@ -1,12 +1,11 @@ // 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.Replays; +using osu.Game.Rulesets.Scoring; -namespace osu.Game.Rulesets.Replays +namespace osu.Game.Rulesets.Pippidon.Scoring { - public interface IAutoGenerator + public class PippidonScoreProcessor : ScoreProcessor { - Replay Generate(); } } diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs new file mode 100644 index 0000000000..d923963bef --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Input; +using osu.Game.Beatmaps; +using osu.Game.Input.Handlers; +using osu.Game.Replays; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Pippidon.Objects; +using osu.Game.Rulesets.Pippidon.Objects.Drawables; +using osu.Game.Rulesets.Pippidon.Replays; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Pippidon.UI +{ + [Cached] + public class DrawablePippidonRuleset : DrawableRuleset + { + public DrawablePippidonRuleset(PippidonRuleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + : base(ruleset, beatmap, mods) + { + } + + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new PippidonPlayfieldAdjustmentContainer(); + + protected override Playfield CreatePlayfield() => new PippidonPlayfield(); + + protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new PippidonFramedReplayInputHandler(replay); + + public override DrawableHitObject CreateDrawableRepresentation(PippidonHitObject h) => new DrawablePippidonHitObject(h); + + protected override PassThroughInputManager CreateInputManager() => new PippidonInputManager(Ruleset?.RulesetInfo); + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonCursorContainer.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonCursorContainer.cs new file mode 100644 index 0000000000..9de3f4ba14 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonCursorContainer.cs @@ -0,0 +1,34 @@ +// 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.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Pippidon.UI +{ + public class PippidonCursorContainer : GameplayCursorContainer + { + private Sprite cursorSprite; + private Texture cursorTexture; + + protected override Drawable CreateCursor() => cursorSprite = new Sprite + { + Scale = new Vector2(0.5f), + Origin = Anchor.Centre, + Texture = cursorTexture, + }; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + cursorTexture = textures.Get("character"); + + if (cursorSprite != null) + cursorSprite.Texture = cursorTexture; + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs new file mode 100644 index 0000000000..b5a97c5ea3 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs @@ -0,0 +1,24 @@ +// 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.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Pippidon.UI +{ + [Cached] + public class PippidonPlayfield : Playfield + { + protected override GameplayCursorContainer CreateCursor() => new PippidonCursorContainer(); + + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + HitObjectContainer, + }); + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfieldAdjustmentContainer.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfieldAdjustmentContainer.cs new file mode 100644 index 0000000000..9236683827 --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfieldAdjustmentContainer.cs @@ -0,0 +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 osu.Framework.Graphics; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Pippidon.UI +{ + public class PippidonPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer + { + public PippidonPlayfieldAdjustmentContainer() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Size = new Vector2(0.8f); + } + } +} diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj new file mode 100644 index 0000000000..61b859f45b --- /dev/null +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + osu.Game.Rulesets.Sample + Library + AnyCPU + osu.Game.Rulesets.Pippidon + + + + + + + + \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-empty/.editorconfig b/Templates/Rulesets/ruleset-scrolling-empty/.editorconfig new file mode 100644 index 0000000000..f3badda9b3 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/.editorconfig @@ -0,0 +1,200 @@ +# EditorConfig is awesome: http://editorconfig.org +root = true + +[*.cs] +end_of_line = crlf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +#Roslyn naming styles + +#PascalCase for public and protected members +dotnet_naming_style.pascalcase.capitalization = pascal_case +dotnet_naming_symbols.public_members.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_members.applicable_kinds = property,method,field,event +dotnet_naming_rule.public_members_pascalcase.severity = error +dotnet_naming_rule.public_members_pascalcase.symbols = public_members +dotnet_naming_rule.public_members_pascalcase.style = pascalcase + +#camelCase for private members +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_symbols.private_members.applicable_accessibilities = private +dotnet_naming_symbols.private_members.applicable_kinds = property,method,field,event +dotnet_naming_rule.private_members_camelcase.severity = warning +dotnet_naming_rule.private_members_camelcase.symbols = private_members +dotnet_naming_rule.private_members_camelcase.style = camelcase + +dotnet_naming_symbols.local_function.applicable_kinds = local_function +dotnet_naming_rule.local_function_camelcase.severity = warning +dotnet_naming_rule.local_function_camelcase.symbols = local_function +dotnet_naming_rule.local_function_camelcase.style = camelcase + +#all_lower for private and local constants/static readonlys +dotnet_naming_style.all_lower.capitalization = all_lower +dotnet_naming_style.all_lower.word_separator = _ + +dotnet_naming_symbols.private_constants.applicable_accessibilities = private +dotnet_naming_symbols.private_constants.required_modifiers = const +dotnet_naming_symbols.private_constants.applicable_kinds = field +dotnet_naming_rule.private_const_all_lower.severity = warning +dotnet_naming_rule.private_const_all_lower.symbols = private_constants +dotnet_naming_rule.private_const_all_lower.style = all_lower + +dotnet_naming_symbols.private_static_readonly.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly.required_modifiers = static,readonly +dotnet_naming_symbols.private_static_readonly.applicable_kinds = field +dotnet_naming_rule.private_static_readonly_all_lower.severity = warning +dotnet_naming_rule.private_static_readonly_all_lower.symbols = private_static_readonly +dotnet_naming_rule.private_static_readonly_all_lower.style = all_lower + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.required_modifiers = const +dotnet_naming_rule.local_const_all_lower.severity = warning +dotnet_naming_rule.local_const_all_lower.symbols = local_constants +dotnet_naming_rule.local_const_all_lower.style = all_lower + +#ALL_UPPER for non private constants/static readonlys +dotnet_naming_style.all_upper.capitalization = all_upper +dotnet_naming_style.all_upper.word_separator = _ + +dotnet_naming_symbols.public_constants.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_constants.required_modifiers = const +dotnet_naming_symbols.public_constants.applicable_kinds = field +dotnet_naming_rule.public_const_all_upper.severity = warning +dotnet_naming_rule.public_const_all_upper.symbols = public_constants +dotnet_naming_rule.public_const_all_upper.style = all_upper + +dotnet_naming_symbols.public_static_readonly.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_static_readonly.required_modifiers = static,readonly +dotnet_naming_symbols.public_static_readonly.applicable_kinds = field +dotnet_naming_rule.public_static_readonly_all_upper.severity = warning +dotnet_naming_rule.public_static_readonly_all_upper.symbols = public_static_readonly +dotnet_naming_rule.public_static_readonly_all_upper.style = all_upper + +#Roslyn formating options + +#Formatting - indentation options +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +#Formatting - new line options +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_open_brace = all +#csharp_new_line_before_members_in_anonymous_types = true +#csharp_new_line_before_members_in_object_initializers = true # Currently no effect in VS/dotnet format (16.4), and makes Rider confusing +csharp_new_line_between_query_expression_clauses = true + +#Formatting - organize using options +dotnet_sort_system_directives_first = true + +#Formatting - spacing options +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false + +#Formatting - wrapping options +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#Roslyn language styles + +#Style - this. qualification +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +#Style - type names +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_for_built_in_types = true:none +csharp_style_var_elsewhere = true:silent + +#Style - modifiers +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +csharp_preferred_modifier_order = public,private,protected,internal,new,abstract,virtual,sealed,override,static,readonly,extern,unsafe,volatile,async:warning + +#Style - parentheses +# Skipped because roslyn cannot separate +-*/ with << >> + +#Style - expression bodies +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_local_functions = true:silent + +#Style - expression preferences +dotnet_style_object_initializer = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_compound_assignment = true:warning + +#Style - null/type checks +dotnet_style_coalesce_expression = true:warning +dotnet_style_null_propagation = true:warning +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_throw_expression = true:silent +csharp_style_conditional_delegate_call = true:warning + +#Style - unused +dotnet_style_readonly_field = true:silent +dotnet_code_quality_unused_parameters = non_public:silent +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_unused_value_assignment_preference = discard_variable:warning + +#Style - variable declaration +csharp_style_inlined_variable_declaration = true:warning +csharp_style_deconstructed_variable_declaration = true:warning + +#Style - other C# 7.x features +dotnet_style_prefer_inferred_tuple_names = true:warning +csharp_prefer_simple_default_expression = true:warning +csharp_style_pattern_local_over_anonymous_function = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent + +#Style - C# 8 features +csharp_prefer_static_local_function = true:warning +csharp_prefer_simple_using_statement = true:silent +csharp_style_prefer_index_operator = true:warning +csharp_style_prefer_range_operator = true:warning +csharp_style_prefer_switch_expression = false:none + +#Supressing roslyn built-in analyzers +# Suppress: EC112 + +#Private method is unused +dotnet_diagnostic.IDE0051.severity = silent +#Private member is unused +dotnet_diagnostic.IDE0052.severity = silent + +#Rules for disposable +dotnet_diagnostic.IDE0067.severity = none +dotnet_diagnostic.IDE0068.severity = none +dotnet_diagnostic.IDE0069.severity = none + +#Disable operator overloads requiring alternate named methods +dotnet_diagnostic.CA2225.severity = none + +# Banned APIs +dotnet_diagnostic.RS0030.severity = error \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-empty/.gitignore b/Templates/Rulesets/ruleset-scrolling-empty/.gitignore new file mode 100644 index 0000000000..940794e60f --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/.gitignore @@ -0,0 +1,288 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs diff --git a/Templates/Rulesets/ruleset-scrolling-empty/.template.config/template.json b/Templates/Rulesets/ruleset-scrolling-empty/.template.config/template.json new file mode 100644 index 0000000000..3eb99a1f9d --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/.template.config/template.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "ppy Pty Ltd", + "classifications": [ + "Console" + ], + "name": "osu! ruleset (scrolling)", + "identity": "ppy.osu.Game.Templates.Rulesets.Scrolling", + "shortName": "ruleset-scrolling", + "tags": { + "language": "C#", + "type": "project" + }, + "sourceName": "EmptyScrolling", + "preferNameDirectory": true +} \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/.vscode/launch.json b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/.vscode/launch.json new file mode 100644 index 0000000000..24e4873ed6 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "VisualTests (Debug)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "${workspaceRoot}/bin/Debug/net5.0/osu.Game.Rulesets.EmptyScrolling.Tests.dll" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build (Debug)", + "env": {}, + "console": "internalConsole" + }, + { + "name": "VisualTests (Release)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "${workspaceRoot}/bin/Release/net5.0/osu.Game.Rulesets.EmptyScrolling.Tests.dll" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build (Release)", + "env": {}, + "console": "internalConsole" + } + ] +} \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/.vscode/tasks.json b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/.vscode/tasks.json new file mode 100644 index 0000000000..00d0dc7d9b --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/.vscode/tasks.json @@ -0,0 +1,47 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Build (Debug)", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Rulesets.EmptyScrolling.Tests.csproj", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "Build (Release)", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Rulesets.EmptyScrolling.Tests.csproj", + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "Restore", + "type": "shell", + "command": "dotnet", + "args": [ + "restore" + ], + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs new file mode 100644 index 0000000000..aed6abb6bf --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuGame.cs @@ -0,0 +1,32 @@ +// 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.Shapes; +using osu.Framework.Platform; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.EmptyScrolling.Tests +{ + public class TestSceneOsuGame : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(GameHost host, OsuGameBase gameBase) + { + OsuGame game = new OsuGame(); + game.SetHost(host); + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + game + }; + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuPlayer.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuPlayer.cs new file mode 100644 index 0000000000..9460576196 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/TestSceneOsuPlayer.cs @@ -0,0 +1,14 @@ +// 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.Game.Tests.Visual; + +namespace osu.Game.Rulesets.EmptyScrolling.Tests +{ + [TestFixture] + public class TestSceneOsuPlayer : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new EmptyScrollingRuleset(); + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/VisualTestRunner.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/VisualTestRunner.cs new file mode 100644 index 0000000000..65cfb2bff4 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/VisualTestRunner.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework; +using osu.Framework.Platform; +using osu.Game.Tests; + +namespace osu.Game.Rulesets.EmptyScrolling.Tests +{ + public static class VisualTestRunner + { + [STAThread] + public static int Main(string[] args) + { + using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) + { + host.Run(new OsuTestBrowser()); + return 0; + } + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj new file mode 100644 index 0000000000..89b551286b --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -0,0 +1,26 @@ + + + osu.Game.Rulesets.EmptyScrolling.Tests.VisualTestRunner + + + + + + false + + + + + + + + + + + + + WinExe + net5.0 + osu.Game.Rulesets.EmptyScrolling.Tests + + \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.sln b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.sln new file mode 100644 index 0000000000..97361e1a7b --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.sln @@ -0,0 +1,96 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29123.88 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.EmptyScrolling", "osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj", "{5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyScrolling.Tests", "osu.Game.Rulesets.EmptyScrolling.Tests\osu.Game.Rulesets.EmptyScrolling.Tests.csproj", "{B4577C85-CB83-462A-BCE3-22FFEB16311D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + VisualTests|Any CPU = VisualTests|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Release|Any CPU.Build.0 = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.VisualTests|Any CPU.ActiveCfg = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.VisualTests|Any CPU.Build.0 = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Release|Any CPU.Build.0 = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.VisualTests|Any CPU.ActiveCfg = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.VisualTests|Any CPU.Build.0 = Debug|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {671B0BEC-2403-45B0-9357-2C97CC517668} + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + Policies = $0 + $0.TextStylePolicy = $1 + $1.EolMarker = Windows + $1.inheritsSet = VisualStudio + $1.inheritsScope = text/plain + $1.scope = text/x-csharp + $0.CSharpFormattingPolicy = $2 + $2.IndentSwitchSection = True + $2.NewLinesForBracesInProperties = True + $2.NewLinesForBracesInAccessors = True + $2.NewLinesForBracesInAnonymousMethods = True + $2.NewLinesForBracesInControlBlocks = True + $2.NewLinesForBracesInAnonymousTypes = True + $2.NewLinesForBracesInObjectCollectionArrayInitializers = True + $2.NewLinesForBracesInLambdaExpressionBody = True + $2.NewLineForElse = True + $2.NewLineForCatch = True + $2.NewLineForFinally = True + $2.NewLineForMembersInObjectInit = True + $2.NewLineForMembersInAnonymousTypes = True + $2.NewLineForClausesInQuery = True + $2.SpacingAfterMethodDeclarationName = False + $2.SpaceAfterMethodCallName = False + $2.SpaceBeforeOpenSquareBracket = False + $2.inheritsSet = Mono + $2.inheritsScope = text/x-csharp + $2.scope = text/x-csharp + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + Policies = $0 + $0.TextStylePolicy = $1 + $1.EolMarker = Windows + $1.inheritsSet = VisualStudio + $1.inheritsScope = text/plain + $1.scope = text/x-csharp + $0.CSharpFormattingPolicy = $2 + $2.IndentSwitchSection = True + $2.NewLinesForBracesInProperties = True + $2.NewLinesForBracesInAccessors = True + $2.NewLinesForBracesInAnonymousMethods = True + $2.NewLinesForBracesInControlBlocks = True + $2.NewLinesForBracesInAnonymousTypes = True + $2.NewLinesForBracesInObjectCollectionArrayInitializers = True + $2.NewLinesForBracesInLambdaExpressionBody = True + $2.NewLineForElse = True + $2.NewLineForCatch = True + $2.NewLineForFinally = True + $2.NewLineForMembersInObjectInit = True + $2.NewLineForMembersInAnonymousTypes = True + $2.NewLineForClausesInQuery = True + $2.SpacingAfterMethodDeclarationName = False + $2.SpaceAfterMethodCallName = False + $2.SpaceBeforeOpenSquareBracket = False + $2.inheritsSet = Mono + $2.inheritsScope = text/x-csharp + $2.scope = text/x-csharp + EndGlobalSection +EndGlobal diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.sln.DotSettings b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.sln.DotSettings new file mode 100644 index 0000000000..aa8f8739c1 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.sln.DotSettings @@ -0,0 +1,934 @@ + + True + True + True + True + ExplicitlyExcluded + ExplicitlyExcluded + SOLUTION + WARNING + WARNING + WARNING + HINT + HINT + WARNING + WARNING + True + WARNING + WARNING + HINT + DO_NOT_SHOW + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + SUGGESTION + HINT + HINT + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + DO_NOT_SHOW + WARNING + HINT + DO_NOT_SHOW + HINT + HINT + ERROR + WARNING + HINT + HINT + HINT + WARNING + WARNING + HINT + DO_NOT_SHOW + HINT + HINT + HINT + HINT + WARNING + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + HINT + HINT + DO_NOT_SHOW + HINT + HINT + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + HINT + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + ERROR + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + WARNING + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + + True + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + HINT + WARNING + WARNING + <?xml version="1.0" encoding="utf-16"?><Profile name="Code Cleanup (peppy)"><CSArrangeThisQualifier>True</CSArrangeThisQualifier><CSUseVar><BehavourStyle>CAN_CHANGE_TO_EXPLICIT</BehavourStyle><LocalVariableStyle>ALWAYS_EXPLICIT</LocalVariableStyle><ForeachVariableStyle>ALWAYS_EXPLICIT</ForeachVariableStyle></CSUseVar><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSUpdateFileHeader>True</CSUpdateFileHeader><CSCodeStyleAttributes ArrangeTypeAccessModifier="False" ArrangeTypeMemberAccessModifier="False" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="False" ArrangeBraces="False" ArrangeAttributes="False" ArrangeArgumentsStyle="False" /><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CSArrangeQualifiers>True</CSArrangeQualifiers></Profile> + Code Cleanup (peppy) + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + Explicit + ExpressionBody + BlockBody + True + NEXT_LINE + True + True + True + True + True + True + True + True + NEXT_LINE + 1 + 1 + NEXT_LINE + MULTILINE + True + True + True + True + NEXT_LINE + 1 + 1 + True + NEXT_LINE + NEVER + NEVER + True + False + True + NEVER + False + False + True + False + False + True + True + False + False + CHOP_IF_LONG + True + 200 + CHOP_IF_LONG + False + False + AABB + API + BPM + GC + GL + GLSL + HID + HTML + HUD + ID + IL + IOS + IP + IPC + JIT + LTRB + MD5 + NS + OS + PM + RGB + RNG + SHA + SRGB + TK + SS + PP + GMT + QAT + BNG + UI + False + HINT + <?xml version="1.0" encoding="utf-16"?> +<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"> + <TypePattern DisplayName="COM interfaces or structs"> + <TypePattern.Match> + <Or> + <And> + <Kind Is="Interface" /> + <Or> + <HasAttribute Name="System.Runtime.InteropServices.InterfaceTypeAttribute" /> + <HasAttribute Name="System.Runtime.InteropServices.ComImport" /> + </Or> + </And> + <Kind Is="Struct" /> + </Or> + </TypePattern.Match> + </TypePattern> + <TypePattern DisplayName="NUnit Test Fixtures" RemoveRegions="All"> + <TypePattern.Match> + <And> + <Kind Is="Class" /> + <HasAttribute Name="NUnit.Framework.TestFixtureAttribute" Inherited="True" /> + </And> + </TypePattern.Match> + <Entry DisplayName="Setup/Teardown Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <Or> + <HasAttribute Name="NUnit.Framework.SetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.TearDownAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureSetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureTearDownAttribute" Inherited="True" /> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="All other members" /> + <Entry Priority="100" DisplayName="Test Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <HasAttribute Name="NUnit.Framework.TestAttribute" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Name /> + </Entry.SortBy> + </Entry> + </TypePattern> + <TypePattern DisplayName="Default Pattern"> + <Group DisplayName="Fields/Properties"> + <Group DisplayName="Public Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Public Properties"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Internal Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Internal Properties"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Protected Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Protected Properties"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Private Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Private Properties"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Constructor/Destructor"> + <Entry DisplayName="Ctor"> + <Entry.Match> + <Kind Is="Constructor" /> + </Entry.Match> + </Entry> + <Region Name="Disposal"> + <Entry DisplayName="Dtor"> + <Entry.Match> + <Kind Is="Destructor" /> + </Entry.Match> + </Entry> + <Entry DisplayName="Dispose()"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Method" /> + <Name Is="Dispose" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Dispose(true)"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Or> + <Virtual /> + <Override /> + </Or> + <Kind Is="Method" /> + <Name Is="Dispose" /> + </And> + </Entry.Match> + </Entry> + </Region> + </Group> + <Group DisplayName="Methods"> + <Group DisplayName="Public"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Internal"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Protected"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Private"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + </Group> + </TypePattern> +</Patterns> + Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +See the LICENCE file in the repository root for full licence text. + + <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> + <Policy Inspect="False" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private methods"><ElementKinds><Kind Name="ASYNC_METHOD" /><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public methods"><ElementKinds><Kind Name="ASYNC_METHOD" /><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="T" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + + True + True + True + True + True + True + True + True + True + True + True + TestFolder + True + True + o!f – Object Initializer: Anchor&Origin + True + constant("Centre") + 0 + True + True + 2.0 + InCSharpFile + ofao + True + Anchor = Anchor.$anchor$, +Origin = Anchor.$anchor$, + True + True + o!f – InternalChildren = [] + True + True + 2.0 + InCSharpFile + ofic + True + InternalChildren = new Drawable[] +{ + $END$ +}; + True + True + o!f – new GridContainer { .. } + True + True + 2.0 + InCSharpFile + ofgc + True + new GridContainer +{ + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { $END$ }, + new Drawable[] { } + } +}; + True + True + o!f – new FillFlowContainer { .. } + True + True + 2.0 + InCSharpFile + offf + True + new FillFlowContainer +{ + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + $END$ + } +}, + True + True + o!f – new Container { .. } + True + True + 2.0 + InCSharpFile + ofcont + True + new Container +{ + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + $END$ + } +}, + True + True + o!f – BackgroundDependencyLoader load() + True + True + 2.0 + InCSharpFile + ofbdl + True + [BackgroundDependencyLoader] +private void load() +{ + $END$ +} + True + True + o!f – new Box { .. } + True + True + 2.0 + InCSharpFile + ofbox + True + new Box +{ + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, +}, + True + True + o!f – Children = [] + True + True + 2.0 + InCSharpFile + ofc + True + Children = new Drawable[] +{ + $END$ +}; + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Beatmaps/EmptyScrollingBeatmapConverter.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Beatmaps/EmptyScrollingBeatmapConverter.cs new file mode 100644 index 0000000000..02fb9a9dd5 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Beatmaps/EmptyScrollingBeatmapConverter.cs @@ -0,0 +1,32 @@ +// 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.Threading; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.EmptyScrolling.Objects; + +namespace osu.Game.Rulesets.EmptyScrolling.Beatmaps +{ + public class EmptyScrollingBeatmapConverter : BeatmapConverter + { + public EmptyScrollingBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + : base(beatmap, ruleset) + { + } + + // todo: Check for conversion types that should be supported (ie. Beatmap.HitObjects.Any(h => h is IHasXPosition)) + // https://github.com/ppy/osu/tree/master/osu.Game/Rulesets/Objects/Types + public override bool CanConvert() => true; + + protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) + { + yield return new EmptyScrollingHitObject + { + Samples = original.Samples, + StartTime = original.StartTime, + }; + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs new file mode 100644 index 0000000000..7f29c4e712 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.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.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.EmptyScrolling +{ + public class EmptyScrollingDifficultyCalculator : DifficultyCalculator + { + public EmptyScrollingDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) + : base(ruleset, beatmap) + { + } + + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + { + return new DifficultyAttributes(mods, skills, 0); + } + + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingInputManager.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingInputManager.cs new file mode 100644 index 0000000000..632e04f301 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingInputManager.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.EmptyScrolling +{ + public class EmptyScrollingInputManager : RulesetInputManager + { + public EmptyScrollingInputManager(RulesetInfo ruleset) + : base(ruleset, 0, SimultaneousBindingMode.Unique) + { + } + } + + public enum EmptyScrollingAction + { + [Description("Button 1")] + Button1, + + [Description("Button 2")] + Button2, + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs new file mode 100644 index 0000000000..c1d4de52b7 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingRuleset.cs @@ -0,0 +1,57 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.EmptyScrolling.Beatmaps; +using osu.Game.Rulesets.EmptyScrolling.Mods; +using osu.Game.Rulesets.EmptyScrolling.UI; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.EmptyScrolling +{ + public class EmptyScrollingRuleset : Ruleset + { + public override string Description => "a very emptyscrolling ruleset"; + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableEmptyScrollingRuleset(this, beatmap, mods); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new EmptyScrollingBeatmapConverter(beatmap, this); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new EmptyScrollingDifficultyCalculator(this, beatmap); + + public override IEnumerable GetModsFor(ModType type) + { + switch (type) + { + case ModType.Automation: + return new[] { new EmptyScrollingModAutoplay() }; + + default: + return new Mod[] { null }; + } + } + + public override string ShortName => "emptyscrolling"; + + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] + { + new KeyBinding(InputKey.Z, EmptyScrollingAction.Button1), + new KeyBinding(InputKey.X, EmptyScrollingAction.Button2), + }; + + public override Drawable CreateIcon() => new SpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = ShortName[0].ToString(), + Font = OsuFont.Default.With(size: 18), + }; + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs new file mode 100644 index 0000000000..6dad1ff43b --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs @@ -0,0 +1,25 @@ +// 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.Mods; +using osu.Game.Rulesets.EmptyScrolling.Objects; +using osu.Game.Rulesets.EmptyScrolling.Replays; +using osu.Game.Scoring; +using osu.Game.Users; +using System.Collections.Generic; + +namespace osu.Game.Rulesets.EmptyScrolling.Mods +{ + public class EmptyScrollingModAutoplay : ModAutoplay + { + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score + { + ScoreInfo = new ScoreInfo + { + User = new User { Username = "sample" }, + }, + Replay = new EmptyScrollingAutoGenerator(beatmap).Generate(), + }; + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/Drawables/DrawableEmptyScrollingHitObject.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/Drawables/DrawableEmptyScrollingHitObject.cs new file mode 100644 index 0000000000..b5ff0cde7c --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/Drawables/DrawableEmptyScrollingHitObject.cs @@ -0,0 +1,48 @@ +// 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.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.EmptyScrolling.Objects.Drawables +{ + public class DrawableEmptyScrollingHitObject : DrawableHitObject + { + public DrawableEmptyScrollingHitObject(EmptyScrollingHitObject hitObject) + : base(hitObject) + { + Size = new Vector2(40); + Origin = Anchor.Centre; + + // todo: add visuals. + } + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (timeOffset >= 0) + // todo: implement judgement logic + ApplyResult(r => r.Type = HitResult.Perfect); + } + + protected override void UpdateHitStateTransforms(ArmedState state) + { + const double duration = 1000; + + switch (state) + { + case ArmedState.Hit: + this.FadeOut(duration, Easing.OutQuint).Expire(); + break; + + case ArmedState.Miss: + + this.FadeColour(Color4.Red, duration); + this.FadeOut(duration, Easing.InQuint).Expire(); + break; + } + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/EmptyScrollingHitObject.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/EmptyScrollingHitObject.cs new file mode 100644 index 0000000000..9b469be496 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Objects/EmptyScrollingHitObject.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.EmptyScrolling.Objects +{ + public class EmptyScrollingHitObject : HitObject + { + public override Judgement CreateJudgement() => new Judgement(); + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingAutoGenerator.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingAutoGenerator.cs new file mode 100644 index 0000000000..1058f756f3 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingAutoGenerator.cs @@ -0,0 +1,33 @@ +// 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.EmptyScrolling.Objects; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.EmptyScrolling.Replays +{ + public class EmptyScrollingAutoGenerator : AutoGenerator + { + public new Beatmap Beatmap => (Beatmap)base.Beatmap; + + public EmptyScrollingAutoGenerator(IBeatmap beatmap) + : base(beatmap) + { + } + + protected override void GenerateFrames() + { + Frames.Add(new EmptyScrollingReplayFrame()); + + foreach (EmptyScrollingHitObject hitObject in Beatmap.HitObjects) + { + Frames.Add(new EmptyScrollingReplayFrame + { + Time = hitObject.StartTime + // todo: add required inputs and extra frames. + }); + } + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingFramedReplayInputHandler.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingFramedReplayInputHandler.cs new file mode 100644 index 0000000000..4b998cfca3 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingFramedReplayInputHandler.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Input.StateChanges; +using osu.Game.Replays; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.EmptyScrolling.Replays +{ + public class EmptyScrollingFramedReplayInputHandler : FramedReplayInputHandler + { + public EmptyScrollingFramedReplayInputHandler(Replay replay) + : base(replay) + { + } + + protected override bool IsImportant(EmptyScrollingReplayFrame frame) => frame.Actions.Any(); + + public override void CollectPendingInputs(List inputs) + { + inputs.Add(new ReplayState + { + PressedActions = CurrentFrame?.Actions ?? new List(), + }); + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs new file mode 100644 index 0000000000..2f19cffd2a --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Replays/EmptyScrollingReplayFrame.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.EmptyScrolling.Replays +{ + public class EmptyScrollingReplayFrame : ReplayFrame + { + public List Actions = new List(); + + public EmptyScrollingReplayFrame(EmptyScrollingAction? button = null) + { + if (button.HasValue) + Actions.Add(button.Value); + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/DrawableEmptyScrollingRuleset.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/DrawableEmptyScrollingRuleset.cs new file mode 100644 index 0000000000..620a4abc51 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/DrawableEmptyScrollingRuleset.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Input; +using osu.Game.Beatmaps; +using osu.Game.Input.Handlers; +using osu.Game.Replays; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.EmptyScrolling.Objects; +using osu.Game.Rulesets.EmptyScrolling.Objects.Drawables; +using osu.Game.Rulesets.EmptyScrolling.Replays; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Rulesets.EmptyScrolling.UI +{ + [Cached] + public class DrawableEmptyScrollingRuleset : DrawableScrollingRuleset + { + public DrawableEmptyScrollingRuleset(EmptyScrollingRuleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + : base(ruleset, beatmap, mods) + { + Direction.Value = ScrollingDirection.Left; + TimeRange.Value = 6000; + } + + protected override Playfield CreatePlayfield() => new EmptyScrollingPlayfield(); + + protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new EmptyScrollingFramedReplayInputHandler(replay); + + public override DrawableHitObject CreateDrawableRepresentation(EmptyScrollingHitObject h) => new DrawableEmptyScrollingHitObject(h); + + protected override PassThroughInputManager CreateInputManager() => new EmptyScrollingInputManager(Ruleset?.RulesetInfo); + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/EmptyScrollingPlayfield.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/EmptyScrollingPlayfield.cs new file mode 100644 index 0000000000..56620e44b3 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/UI/EmptyScrollingPlayfield.cs @@ -0,0 +1,22 @@ +// 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.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Rulesets.EmptyScrolling.UI +{ + [Cached] + public class EmptyScrollingPlayfield : ScrollingPlayfield + { + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + HitObjectContainer, + }); + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj new file mode 100644 index 0000000000..9dce3c9a0a --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/osu.Game.Rulesets.EmptyScrolling.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + osu.Game.Rulesets.Sample + Library + AnyCPU + osu.Game.Rulesets.EmptyScrolling + + + + + + + + \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-example/.editorconfig b/Templates/Rulesets/ruleset-scrolling-example/.editorconfig new file mode 100644 index 0000000000..f3badda9b3 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/.editorconfig @@ -0,0 +1,200 @@ +# EditorConfig is awesome: http://editorconfig.org +root = true + +[*.cs] +end_of_line = crlf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +#Roslyn naming styles + +#PascalCase for public and protected members +dotnet_naming_style.pascalcase.capitalization = pascal_case +dotnet_naming_symbols.public_members.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_members.applicable_kinds = property,method,field,event +dotnet_naming_rule.public_members_pascalcase.severity = error +dotnet_naming_rule.public_members_pascalcase.symbols = public_members +dotnet_naming_rule.public_members_pascalcase.style = pascalcase + +#camelCase for private members +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_symbols.private_members.applicable_accessibilities = private +dotnet_naming_symbols.private_members.applicable_kinds = property,method,field,event +dotnet_naming_rule.private_members_camelcase.severity = warning +dotnet_naming_rule.private_members_camelcase.symbols = private_members +dotnet_naming_rule.private_members_camelcase.style = camelcase + +dotnet_naming_symbols.local_function.applicable_kinds = local_function +dotnet_naming_rule.local_function_camelcase.severity = warning +dotnet_naming_rule.local_function_camelcase.symbols = local_function +dotnet_naming_rule.local_function_camelcase.style = camelcase + +#all_lower for private and local constants/static readonlys +dotnet_naming_style.all_lower.capitalization = all_lower +dotnet_naming_style.all_lower.word_separator = _ + +dotnet_naming_symbols.private_constants.applicable_accessibilities = private +dotnet_naming_symbols.private_constants.required_modifiers = const +dotnet_naming_symbols.private_constants.applicable_kinds = field +dotnet_naming_rule.private_const_all_lower.severity = warning +dotnet_naming_rule.private_const_all_lower.symbols = private_constants +dotnet_naming_rule.private_const_all_lower.style = all_lower + +dotnet_naming_symbols.private_static_readonly.applicable_accessibilities = private +dotnet_naming_symbols.private_static_readonly.required_modifiers = static,readonly +dotnet_naming_symbols.private_static_readonly.applicable_kinds = field +dotnet_naming_rule.private_static_readonly_all_lower.severity = warning +dotnet_naming_rule.private_static_readonly_all_lower.symbols = private_static_readonly +dotnet_naming_rule.private_static_readonly_all_lower.style = all_lower + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.required_modifiers = const +dotnet_naming_rule.local_const_all_lower.severity = warning +dotnet_naming_rule.local_const_all_lower.symbols = local_constants +dotnet_naming_rule.local_const_all_lower.style = all_lower + +#ALL_UPPER for non private constants/static readonlys +dotnet_naming_style.all_upper.capitalization = all_upper +dotnet_naming_style.all_upper.word_separator = _ + +dotnet_naming_symbols.public_constants.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_constants.required_modifiers = const +dotnet_naming_symbols.public_constants.applicable_kinds = field +dotnet_naming_rule.public_const_all_upper.severity = warning +dotnet_naming_rule.public_const_all_upper.symbols = public_constants +dotnet_naming_rule.public_const_all_upper.style = all_upper + +dotnet_naming_symbols.public_static_readonly.applicable_accessibilities = public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_static_readonly.required_modifiers = static,readonly +dotnet_naming_symbols.public_static_readonly.applicable_kinds = field +dotnet_naming_rule.public_static_readonly_all_upper.severity = warning +dotnet_naming_rule.public_static_readonly_all_upper.symbols = public_static_readonly +dotnet_naming_rule.public_static_readonly_all_upper.style = all_upper + +#Roslyn formating options + +#Formatting - indentation options +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +#Formatting - new line options +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_open_brace = all +#csharp_new_line_before_members_in_anonymous_types = true +#csharp_new_line_before_members_in_object_initializers = true # Currently no effect in VS/dotnet format (16.4), and makes Rider confusing +csharp_new_line_between_query_expression_clauses = true + +#Formatting - organize using options +dotnet_sort_system_directives_first = true + +#Formatting - spacing options +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false + +#Formatting - wrapping options +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#Roslyn language styles + +#Style - this. qualification +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +#Style - type names +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning +csharp_style_var_when_type_is_apparent = true:none +csharp_style_var_for_built_in_types = true:none +csharp_style_var_elsewhere = true:silent + +#Style - modifiers +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +csharp_preferred_modifier_order = public,private,protected,internal,new,abstract,virtual,sealed,override,static,readonly,extern,unsafe,volatile,async:warning + +#Style - parentheses +# Skipped because roslyn cannot separate +-*/ with << >> + +#Style - expression bodies +csharp_style_expression_bodied_accessors = true:warning +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = true:warning +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_local_functions = true:silent + +#Style - expression preferences +dotnet_style_object_initializer = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_compound_assignment = true:warning + +#Style - null/type checks +dotnet_style_coalesce_expression = true:warning +dotnet_style_null_propagation = true:warning +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_throw_expression = true:silent +csharp_style_conditional_delegate_call = true:warning + +#Style - unused +dotnet_style_readonly_field = true:silent +dotnet_code_quality_unused_parameters = non_public:silent +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_unused_value_assignment_preference = discard_variable:warning + +#Style - variable declaration +csharp_style_inlined_variable_declaration = true:warning +csharp_style_deconstructed_variable_declaration = true:warning + +#Style - other C# 7.x features +dotnet_style_prefer_inferred_tuple_names = true:warning +csharp_prefer_simple_default_expression = true:warning +csharp_style_pattern_local_over_anonymous_function = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent + +#Style - C# 8 features +csharp_prefer_static_local_function = true:warning +csharp_prefer_simple_using_statement = true:silent +csharp_style_prefer_index_operator = true:warning +csharp_style_prefer_range_operator = true:warning +csharp_style_prefer_switch_expression = false:none + +#Supressing roslyn built-in analyzers +# Suppress: EC112 + +#Private method is unused +dotnet_diagnostic.IDE0051.severity = silent +#Private member is unused +dotnet_diagnostic.IDE0052.severity = silent + +#Rules for disposable +dotnet_diagnostic.IDE0067.severity = none +dotnet_diagnostic.IDE0068.severity = none +dotnet_diagnostic.IDE0069.severity = none + +#Disable operator overloads requiring alternate named methods +dotnet_diagnostic.CA2225.severity = none + +# Banned APIs +dotnet_diagnostic.RS0030.severity = error \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-example/.gitignore b/Templates/Rulesets/ruleset-scrolling-example/.gitignore new file mode 100644 index 0000000000..940794e60f --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/.gitignore @@ -0,0 +1,288 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs diff --git a/Templates/Rulesets/ruleset-scrolling-example/.template.config/template.json b/Templates/Rulesets/ruleset-scrolling-example/.template.config/template.json new file mode 100644 index 0000000000..a1c097f1c8 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/.template.config/template.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "ppy Pty Ltd", + "classifications": [ + "Console" + ], + "name": "osu! ruleset (scrolling pippidon example)", + "identity": "ppy.osu.Game.Templates.Rulesets.Scrolling.Pippidon", + "shortName": "ruleset-scrolling-example", + "tags": { + "language": "C#", + "type": "project" + }, + "sourceName": "Pippidon", + "preferNameDirectory": true +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/launch.json b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/launch.json new file mode 100644 index 0000000000..bd9db14259 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "VisualTests (Debug)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "${workspaceRoot}/bin/Debug/net5.0/osu.Game.Rulesets.Pippidon.Tests.dll" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build (Debug)", + "env": {}, + "console": "internalConsole" + }, + { + "name": "VisualTests (Release)", + "type": "coreclr", + "request": "launch", + "program": "dotnet", + "args": [ + "${workspaceRoot}/bin/Release/net5.0/osu.Game.Rulesets.Pippidon.Tests.dll" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build (Release)", + "env": {}, + "console": "internalConsole" + } + ] +} \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/tasks.json b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/tasks.json new file mode 100644 index 0000000000..0ee07c1036 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/.vscode/tasks.json @@ -0,0 +1,47 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Build (Debug)", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Rulesets.Pippidon.Tests.csproj", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "Build (Release)", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Rulesets.Pippidon.Tests.csproj", + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "Restore", + "type": "shell", + "command": "dotnet", + "args": [ + "restore" + ], + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs new file mode 100644 index 0000000000..270d906b01 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuGame.cs @@ -0,0 +1,32 @@ +// 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.Shapes; +using osu.Framework.Platform; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Pippidon.Tests +{ + public class TestSceneOsuGame : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(GameHost host, OsuGameBase gameBase) + { + OsuGame game = new OsuGame(); + game.SetHost(host); + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + game + }; + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs new file mode 100644 index 0000000000..f00528900c --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/TestSceneOsuPlayer.cs @@ -0,0 +1,14 @@ +// 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.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Pippidon.Tests +{ + [TestFixture] + public class TestSceneOsuPlayer : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new PippidonRuleset(); + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/VisualTestRunner.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/VisualTestRunner.cs new file mode 100644 index 0000000000..fd6bd9b714 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/VisualTestRunner.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework; +using osu.Framework.Platform; +using osu.Game.Tests; + +namespace osu.Game.Rulesets.Pippidon.Tests +{ + public static class VisualTestRunner + { + [STAThread] + public static int Main(string[] args) + { + using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) + { + host.Run(new OsuTestBrowser()); + return 0; + } + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj new file mode 100644 index 0000000000..d7c116411a --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -0,0 +1,26 @@ + + + osu.Game.Rulesets.Pippidon.Tests.VisualTestRunner + + + + + + false + + + + + + + + + + + + + WinExe + net5.0 + osu.Game.Rulesets.Pippidon.Tests + + \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.sln b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.sln new file mode 100644 index 0000000000..bccffcd7ff --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.sln @@ -0,0 +1,96 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29123.88 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.Pippidon", "osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj", "{5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon.Tests", "osu.Game.Rulesets.Pippidon.Tests\osu.Game.Rulesets.Pippidon.Tests.csproj", "{B4577C85-CB83-462A-BCE3-22FFEB16311D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + VisualTests|Any CPU = VisualTests|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.Release|Any CPU.Build.0 = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.VisualTests|Any CPU.ActiveCfg = Release|Any CPU + {5AE1F0F1-DAFA-46E7-959C-DA233B7C87E9}.VisualTests|Any CPU.Build.0 = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.Release|Any CPU.Build.0 = Release|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.VisualTests|Any CPU.ActiveCfg = Debug|Any CPU + {B4577C85-CB83-462A-BCE3-22FFEB16311D}.VisualTests|Any CPU.Build.0 = Debug|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {671B0BEC-2403-45B0-9357-2C97CC517668} + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + Policies = $0 + $0.TextStylePolicy = $1 + $1.EolMarker = Windows + $1.inheritsSet = VisualStudio + $1.inheritsScope = text/plain + $1.scope = text/x-csharp + $0.CSharpFormattingPolicy = $2 + $2.IndentSwitchSection = True + $2.NewLinesForBracesInProperties = True + $2.NewLinesForBracesInAccessors = True + $2.NewLinesForBracesInAnonymousMethods = True + $2.NewLinesForBracesInControlBlocks = True + $2.NewLinesForBracesInAnonymousTypes = True + $2.NewLinesForBracesInObjectCollectionArrayInitializers = True + $2.NewLinesForBracesInLambdaExpressionBody = True + $2.NewLineForElse = True + $2.NewLineForCatch = True + $2.NewLineForFinally = True + $2.NewLineForMembersInObjectInit = True + $2.NewLineForMembersInAnonymousTypes = True + $2.NewLineForClausesInQuery = True + $2.SpacingAfterMethodDeclarationName = False + $2.SpaceAfterMethodCallName = False + $2.SpaceBeforeOpenSquareBracket = False + $2.inheritsSet = Mono + $2.inheritsScope = text/x-csharp + $2.scope = text/x-csharp + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + Policies = $0 + $0.TextStylePolicy = $1 + $1.EolMarker = Windows + $1.inheritsSet = VisualStudio + $1.inheritsScope = text/plain + $1.scope = text/x-csharp + $0.CSharpFormattingPolicy = $2 + $2.IndentSwitchSection = True + $2.NewLinesForBracesInProperties = True + $2.NewLinesForBracesInAccessors = True + $2.NewLinesForBracesInAnonymousMethods = True + $2.NewLinesForBracesInControlBlocks = True + $2.NewLinesForBracesInAnonymousTypes = True + $2.NewLinesForBracesInObjectCollectionArrayInitializers = True + $2.NewLinesForBracesInLambdaExpressionBody = True + $2.NewLineForElse = True + $2.NewLineForCatch = True + $2.NewLineForFinally = True + $2.NewLineForMembersInObjectInit = True + $2.NewLineForMembersInAnonymousTypes = True + $2.NewLineForClausesInQuery = True + $2.SpacingAfterMethodDeclarationName = False + $2.SpaceAfterMethodCallName = False + $2.SpaceBeforeOpenSquareBracket = False + $2.inheritsSet = Mono + $2.inheritsScope = text/x-csharp + $2.scope = text/x-csharp + EndGlobalSection +EndGlobal diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.sln.DotSettings b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.sln.DotSettings new file mode 100644 index 0000000000..aa8f8739c1 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.sln.DotSettings @@ -0,0 +1,934 @@ + + True + True + True + True + ExplicitlyExcluded + ExplicitlyExcluded + SOLUTION + WARNING + WARNING + WARNING + HINT + HINT + WARNING + WARNING + True + WARNING + WARNING + HINT + DO_NOT_SHOW + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + SUGGESTION + HINT + HINT + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + DO_NOT_SHOW + WARNING + HINT + DO_NOT_SHOW + HINT + HINT + ERROR + WARNING + HINT + HINT + HINT + WARNING + WARNING + HINT + DO_NOT_SHOW + HINT + HINT + HINT + HINT + WARNING + WARNING + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + HINT + HINT + DO_NOT_SHOW + HINT + HINT + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + HINT + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + ERROR + WARNING + WARNING + HINT + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + WARNING + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + WARNING + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + HINT + DO_NOT_SHOW + WARNING + WARNING + WARNING + WARNING + WARNING + WARNING + + True + WARNING + WARNING + WARNING + WARNING + WARNING + HINT + HINT + WARNING + WARNING + <?xml version="1.0" encoding="utf-16"?><Profile name="Code Cleanup (peppy)"><CSArrangeThisQualifier>True</CSArrangeThisQualifier><CSUseVar><BehavourStyle>CAN_CHANGE_TO_EXPLICIT</BehavourStyle><LocalVariableStyle>ALWAYS_EXPLICIT</LocalVariableStyle><ForeachVariableStyle>ALWAYS_EXPLICIT</ForeachVariableStyle></CSUseVar><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSUpdateFileHeader>True</CSUpdateFileHeader><CSCodeStyleAttributes ArrangeTypeAccessModifier="False" ArrangeTypeMemberAccessModifier="False" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="False" ArrangeBraces="False" ArrangeAttributes="False" ArrangeArgumentsStyle="False" /><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CSArrangeQualifiers>True</CSArrangeQualifiers></Profile> + Code Cleanup (peppy) + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + RequiredForMultiline + Explicit + ExpressionBody + BlockBody + True + NEXT_LINE + True + True + True + True + True + True + True + True + NEXT_LINE + 1 + 1 + NEXT_LINE + MULTILINE + True + True + True + True + NEXT_LINE + 1 + 1 + True + NEXT_LINE + NEVER + NEVER + True + False + True + NEVER + False + False + True + False + False + True + True + False + False + CHOP_IF_LONG + True + 200 + CHOP_IF_LONG + False + False + AABB + API + BPM + GC + GL + GLSL + HID + HTML + HUD + ID + IL + IOS + IP + IPC + JIT + LTRB + MD5 + NS + OS + PM + RGB + RNG + SHA + SRGB + TK + SS + PP + GMT + QAT + BNG + UI + False + HINT + <?xml version="1.0" encoding="utf-16"?> +<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"> + <TypePattern DisplayName="COM interfaces or structs"> + <TypePattern.Match> + <Or> + <And> + <Kind Is="Interface" /> + <Or> + <HasAttribute Name="System.Runtime.InteropServices.InterfaceTypeAttribute" /> + <HasAttribute Name="System.Runtime.InteropServices.ComImport" /> + </Or> + </And> + <Kind Is="Struct" /> + </Or> + </TypePattern.Match> + </TypePattern> + <TypePattern DisplayName="NUnit Test Fixtures" RemoveRegions="All"> + <TypePattern.Match> + <And> + <Kind Is="Class" /> + <HasAttribute Name="NUnit.Framework.TestFixtureAttribute" Inherited="True" /> + </And> + </TypePattern.Match> + <Entry DisplayName="Setup/Teardown Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <Or> + <HasAttribute Name="NUnit.Framework.SetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.TearDownAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureSetUpAttribute" Inherited="True" /> + <HasAttribute Name="NUnit.Framework.FixtureTearDownAttribute" Inherited="True" /> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="All other members" /> + <Entry Priority="100" DisplayName="Test Methods"> + <Entry.Match> + <And> + <Kind Is="Method" /> + <HasAttribute Name="NUnit.Framework.TestAttribute" /> + </And> + </Entry.Match> + <Entry.SortBy> + <Name /> + </Entry.SortBy> + </Entry> + </TypePattern> + <TypePattern DisplayName="Default Pattern"> + <Group DisplayName="Fields/Properties"> + <Group DisplayName="Public Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Public Properties"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Internal Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Internal Properties"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Protected Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Protected Properties"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + <Group DisplayName="Private Fields"> + <Entry DisplayName="Constant Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Or> + <Kind Is="Constant" /> + <Readonly /> + <And> + <Static /> + <Readonly /> + </And> + </Or> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Static Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Static /> + <Not> + <Readonly /> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Normal Fields"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Not> + <Or> + <Static /> + <Readonly /> + </Or> + </Not> + <Kind Is="Field" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Entry DisplayName="Private Properties"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Kind Is="Property" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Constructor/Destructor"> + <Entry DisplayName="Ctor"> + <Entry.Match> + <Kind Is="Constructor" /> + </Entry.Match> + </Entry> + <Region Name="Disposal"> + <Entry DisplayName="Dtor"> + <Entry.Match> + <Kind Is="Destructor" /> + </Entry.Match> + </Entry> + <Entry DisplayName="Dispose()"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Kind Is="Method" /> + <Name Is="Dispose" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Dispose(true)"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Or> + <Virtual /> + <Override /> + </Or> + <Kind Is="Method" /> + <Name Is="Dispose" /> + </And> + </Entry.Match> + </Entry> + </Region> + </Group> + <Group DisplayName="Methods"> + <Group DisplayName="Public"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Public" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Internal"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Internal" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Protected"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Protected" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + <Group DisplayName="Private"> + <Entry DisplayName="Static Methods"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Static /> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + <Entry DisplayName="Methods"> + <Entry.Match> + <And> + <Access Is="Private" /> + <Not> + <Static /> + </Not> + <Kind Is="Method" /> + </And> + </Entry.Match> + </Entry> + </Group> + </Group> + </TypePattern> +</Patterns> + Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +See the LICENCE file in the repository root for full licence text. + + <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> + <Policy Inspect="False" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aa_bb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private methods"><ElementKinds><Kind Name="ASYNC_METHOD" /><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public methods"><ElementKinds><Kind Name="ASYNC_METHOD" /><Kind Name="METHOD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Private" Description="private properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public" Description="internal/protected/public properties"><ElementKinds><Kind Name="PROPERTY" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /></Policy> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="I" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="T" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + + True + True + True + True + True + True + True + True + True + True + True + TestFolder + True + True + o!f – Object Initializer: Anchor&Origin + True + constant("Centre") + 0 + True + True + 2.0 + InCSharpFile + ofao + True + Anchor = Anchor.$anchor$, +Origin = Anchor.$anchor$, + True + True + o!f – InternalChildren = [] + True + True + 2.0 + InCSharpFile + ofic + True + InternalChildren = new Drawable[] +{ + $END$ +}; + True + True + o!f – new GridContainer { .. } + True + True + 2.0 + InCSharpFile + ofgc + True + new GridContainer +{ + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { $END$ }, + new Drawable[] { } + } +}; + True + True + o!f – new FillFlowContainer { .. } + True + True + 2.0 + InCSharpFile + offf + True + new FillFlowContainer +{ + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + $END$ + } +}, + True + True + o!f – new Container { .. } + True + True + 2.0 + InCSharpFile + ofcont + True + new Container +{ + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + $END$ + } +}, + True + True + o!f – BackgroundDependencyLoader load() + True + True + 2.0 + InCSharpFile + ofbdl + True + [BackgroundDependencyLoader] +private void load() +{ + $END$ +} + True + True + o!f – new Box { .. } + True + True + 2.0 + InCSharpFile + ofbox + True + new Box +{ + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, +}, + True + True + o!f – Children = [] + True + True + 2.0 + InCSharpFile + ofc + True + Children = new Drawable[] +{ + $END$ +}; + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs new file mode 100644 index 0000000000..8f0b31ef1b --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Pippidon.Objects; +using osu.Game.Rulesets.Pippidon.UI; +using osuTK; + +namespace osu.Game.Rulesets.Pippidon.Beatmaps +{ + public class PippidonBeatmapConverter : BeatmapConverter + { + private readonly float minPosition; + private readonly float maxPosition; + + public PippidonBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + : base(beatmap, ruleset) + { + minPosition = beatmap.HitObjects.Min(getUsablePosition); + maxPosition = beatmap.HitObjects.Max(getUsablePosition); + } + + public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition && h is IHasYPosition); + + protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) + { + yield return new PippidonHitObject + { + Samples = original.Samples, + StartTime = original.StartTime, + Lane = getLane(original) + }; + } + + private int getLane(HitObject hitObject) => (int)MathHelper.Clamp( + (getUsablePosition(hitObject) - minPosition) / (maxPosition - minPosition) * PippidonPlayfield.LANE_COUNT, 0, PippidonPlayfield.LANE_COUNT - 1); + + private float getUsablePosition(HitObject h) => (h as IHasYPosition)?.Y ?? ((IHasXPosition)h).X; + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs new file mode 100644 index 0000000000..8ea334c99c --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Pippidon.Objects; +using osu.Game.Rulesets.Pippidon.Replays; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Rulesets.Pippidon.Mods +{ + public class PippidonModAutoplay : ModAutoplay + { + public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score + { + ScoreInfo = new ScoreInfo + { + User = new User { Username = "sample" }, + }, + Replay = new PippidonAutoGenerator(beatmap).Generate(), + }; + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs new file mode 100644 index 0000000000..e458cacef9 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Pippidon.UI; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Pippidon.Objects.Drawables +{ + public class DrawablePippidonHitObject : DrawableHitObject + { + private BindableNumber currentLane; + + public DrawablePippidonHitObject(PippidonHitObject hitObject) + : base(hitObject) + { + Size = new Vector2(40); + + Origin = Anchor.Centre; + Y = hitObject.Lane * PippidonPlayfield.LANE_HEIGHT; + } + + [BackgroundDependencyLoader] + private void load(PippidonPlayfield playfield, TextureStore textures) + { + AddInternal(new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("coin"), + }); + + currentLane = playfield.CurrentLane.GetBoundCopy(); + } + + public override IEnumerable GetSamples() => new[] + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK) + }; + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (timeOffset >= 0) + ApplyResult(r => r.Type = currentLane.Value == HitObject.Lane ? HitResult.Perfect : HitResult.Miss); + } + + protected override void UpdateHitStateTransforms(ArmedState state) + { + switch (state) + { + case ArmedState.Hit: + this.ScaleTo(5, 1500, Easing.OutQuint).FadeOut(1500, Easing.OutQuint).Expire(); + break; + + case ArmedState.Miss: + + const double duration = 1000; + + this.ScaleTo(0.8f, duration, Easing.OutQuint); + this.MoveToOffset(new Vector2(0, 10), duration, Easing.In); + this.FadeColour(Color4.Red, duration / 2, Easing.OutQuint).Then().FadeOut(duration / 2, Easing.InQuint).Expire(); + break; + } + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.cs new file mode 100644 index 0000000000..9dd135479f --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/PippidonHitObject.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.Judgements; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Pippidon.Objects +{ + public class PippidonHitObject : HitObject + { + /// + /// Range = [-1,1] + /// + public int Lane; + + public override Judgement CreateJudgement() => new Judgement(); + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs new file mode 100644 index 0000000000..f6340f6c25 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.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.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Pippidon +{ + public class PippidonDifficultyCalculator : DifficultyCalculator + { + public PippidonDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) + : base(ruleset, beatmap) + { + } + + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + { + return new DifficultyAttributes(mods, skills, 0); + } + + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); + + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs new file mode 100644 index 0000000000..c9e6e6faaa --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonInputManager.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Pippidon +{ + public class PippidonInputManager : RulesetInputManager + { + public PippidonInputManager(RulesetInfo ruleset) + : base(ruleset, 0, SimultaneousBindingMode.Unique) + { + } + } + + public enum PippidonAction + { + [Description("Move up")] + MoveUp, + + [Description("Move down")] + MoveDown, + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs new file mode 100644 index 0000000000..ede00f1510 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonRuleset.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Pippidon.Beatmaps; +using osu.Game.Rulesets.Pippidon.Mods; +using osu.Game.Rulesets.Pippidon.UI; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Pippidon +{ + public class PippidonRuleset : Ruleset + { + public override string Description => "gather the osu!coins"; + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawablePippidonRuleset(this, beatmap, mods); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new PippidonBeatmapConverter(beatmap, this); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new PippidonDifficultyCalculator(this, beatmap); + + public override IEnumerable GetModsFor(ModType type) + { + switch (type) + { + case ModType.Automation: + return new[] { new PippidonModAutoplay() }; + + default: + return new Mod[] { null }; + } + } + + public override string ShortName => "pippidon"; + + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] + { + new KeyBinding(InputKey.W, PippidonAction.MoveUp), + new KeyBinding(InputKey.S, PippidonAction.MoveDown), + }; + + public override Drawable CreateIcon() => new Sprite + { + Margin = new MarginPadding { Top = 3 }, + Texture = new TextureStore(new TextureLoaderStore(CreateResourceStore()), false).Get("Textures/coin"), + }; + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonAutoGenerator.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonAutoGenerator.cs new file mode 100644 index 0000000000..724026273d --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonAutoGenerator.cs @@ -0,0 +1,60 @@ +// 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.Beatmaps; +using osu.Game.Rulesets.Pippidon.Objects; +using osu.Game.Rulesets.Pippidon.UI; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.Pippidon.Replays +{ + public class PippidonAutoGenerator : AutoGenerator + { + public new Beatmap Beatmap => (Beatmap)base.Beatmap; + + public PippidonAutoGenerator(IBeatmap beatmap) + : base(beatmap) + { + } + + protected override void GenerateFrames() + { + int currentLane = 0; + + Frames.Add(new PippidonReplayFrame()); + + foreach (PippidonHitObject hitObject in Beatmap.HitObjects) + { + if (currentLane == hitObject.Lane) + continue; + + int totalTravel = Math.Abs(hitObject.Lane - currentLane); + var direction = hitObject.Lane > currentLane ? PippidonAction.MoveDown : PippidonAction.MoveUp; + + double time = hitObject.StartTime - 5; + + if (totalTravel == PippidonPlayfield.LANE_COUNT - 1) + addFrame(time, direction == PippidonAction.MoveDown ? PippidonAction.MoveUp : PippidonAction.MoveDown); + else + { + time -= totalTravel * KEY_UP_DELAY; + + for (int i = 0; i < totalTravel; i++) + { + addFrame(time, direction); + time += KEY_UP_DELAY; + } + } + + currentLane = hitObject.Lane; + } + } + + private void addFrame(double time, PippidonAction direction) + { + Frames.Add(new PippidonReplayFrame(direction) { Time = time }); + Frames.Add(new PippidonReplayFrame { Time = time + KEY_UP_DELAY }); //Release the keys as well + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonFramedReplayInputHandler.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonFramedReplayInputHandler.cs new file mode 100644 index 0000000000..7652357b4d --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonFramedReplayInputHandler.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Input.StateChanges; +using osu.Game.Replays; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.Pippidon.Replays +{ + public class PippidonFramedReplayInputHandler : FramedReplayInputHandler + { + public PippidonFramedReplayInputHandler(Replay replay) + : base(replay) + { + } + + protected override bool IsImportant(PippidonReplayFrame frame) => frame.Actions.Any(); + + public override void CollectPendingInputs(List inputs) + { + inputs.Add(new ReplayState + { + PressedActions = CurrentFrame?.Actions ?? new List(), + }); + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs new file mode 100644 index 0000000000..468ac9c725 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Replays/PippidonReplayFrame.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.Pippidon.Replays +{ + public class PippidonReplayFrame : ReplayFrame + { + public List Actions = new List(); + + public PippidonReplayFrame(PippidonAction? button = null) + { + if (button.HasValue) + Actions.Add(button.Value); + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Resources/Samples/Gameplay/normal-hitnormal.mp3 b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Resources/Samples/Gameplay/normal-hitnormal.mp3 new file mode 100644 index 0000000000..90b13d1f73 Binary files /dev/null and b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Resources/Samples/Gameplay/normal-hitnormal.mp3 differ diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Resources/Textures/character.png b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Resources/Textures/character.png new file mode 100644 index 0000000000..e79d2528ec Binary files /dev/null and b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Resources/Textures/character.png differ diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Resources/Textures/coin.png b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Resources/Textures/coin.png new file mode 100644 index 0000000000..3cd89c6ce6 Binary files /dev/null and b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Resources/Textures/coin.png differ diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs new file mode 100644 index 0000000000..9a73dd7790 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/DrawablePippidonRuleset.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Input; +using osu.Game.Beatmaps; +using osu.Game.Input.Handlers; +using osu.Game.Replays; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Pippidon.Objects; +using osu.Game.Rulesets.Pippidon.Objects.Drawables; +using osu.Game.Rulesets.Pippidon.Replays; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Rulesets.Pippidon.UI +{ + [Cached] + public class DrawablePippidonRuleset : DrawableScrollingRuleset + { + public DrawablePippidonRuleset(PippidonRuleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + : base(ruleset, beatmap, mods) + { + Direction.Value = ScrollingDirection.Left; + TimeRange.Value = 6000; + } + + protected override Playfield CreatePlayfield() => new PippidonPlayfield(); + + protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new PippidonFramedReplayInputHandler(replay); + + public override DrawableHitObject CreateDrawableRepresentation(PippidonHitObject h) => new DrawablePippidonHitObject(h); + + protected override PassThroughInputManager CreateInputManager() => new PippidonInputManager(Ruleset?.RulesetInfo); + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs new file mode 100644 index 0000000000..dd0a20f1b4 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonCharacter.cs @@ -0,0 +1,87 @@ +// 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.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Rulesets.Pippidon.UI +{ + public class PippidonCharacter : BeatSyncedContainer, IKeyBindingHandler + { + public readonly BindableInt LanePosition = new BindableInt + { + Value = 0, + MinValue = 0, + MaxValue = PippidonPlayfield.LANE_COUNT - 1, + }; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Size = new Vector2(PippidonPlayfield.LANE_HEIGHT); + + Child = new Sprite + { + FillMode = FillMode.Fit, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.2f), + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("character") + }; + + LanePosition.BindValueChanged(e => { this.MoveToY(e.NewValue * PippidonPlayfield.LANE_HEIGHT); }); + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (effectPoint.KiaiMode) + { + bool direction = beatIndex % 2 == 1; + double duration = timingPoint.BeatLength / 2; + + Child.RotateTo(direction ? 10 : -10, duration * 2, Easing.InOutSine); + + Child.Animate(i => i.MoveToY(-10, duration, Easing.Out)) + .Then(i => i.MoveToY(0, duration, Easing.In)); + } + else + { + Child.ClearTransforms(); + Child.RotateTo(0, 500, Easing.Out); + Child.MoveTo(Vector2.Zero, 500, Easing.Out); + } + } + + public bool OnPressed(PippidonAction action) + { + switch (action) + { + case PippidonAction.MoveUp: + changeLane(-1); + return true; + + case PippidonAction.MoveDown: + changeLane(1); + return true; + + default: + return false; + } + } + + public void OnReleased(PippidonAction action) + { + } + + private void changeLane(int change) => LanePosition.Value = (LanePosition.Value + change + PippidonPlayfield.LANE_COUNT) % PippidonPlayfield.LANE_COUNT; + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs new file mode 100644 index 0000000000..0e50030162 --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/UI/PippidonPlayfield.cs @@ -0,0 +1,128 @@ +// 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.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Pippidon.UI +{ + [Cached] + public class PippidonPlayfield : ScrollingPlayfield + { + public const float LANE_HEIGHT = 70; + + public const int LANE_COUNT = 6; + + public BindableInt CurrentLane => pippidon.LanePosition; + + private PippidonCharacter pippidon; + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AddRangeInternal(new Drawable[] + { + new LaneContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Left = 200, + Top = LANE_HEIGHT / 2, + Bottom = LANE_HEIGHT / 2 + }, + Children = new Drawable[] + { + HitObjectContainer, + pippidon = new PippidonCharacter + { + Origin = Anchor.Centre, + }, + } + }, + }, + }); + } + + private class LaneContainer : BeatSyncedContainer + { + private OsuColour colours; + private FillFlowContainer fill; + + private readonly Container content = new Container + { + RelativeSizeAxes = Axes.Both, + }; + + protected override Container Content => content; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + this.colours = colours; + + InternalChildren = new Drawable[] + { + fill = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Colour = colours.BlueLight, + Direction = FillDirection.Vertical, + }, + content, + }; + + for (int i = 0; i < LANE_COUNT; i++) + { + fill.Add(new Lane + { + RelativeSizeAxes = Axes.X, + Height = LANE_HEIGHT, + }); + } + } + + private class Lane : CompositeDrawable + { + public Lane() + { + InternalChildren = new Drawable[] + { + new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 0.95f, + }, + }; + } + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (effectPoint.KiaiMode) + fill.FlashColour(colours.PinkLight, 800, Easing.In); + } + } + } +} diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj new file mode 100644 index 0000000000..61b859f45b --- /dev/null +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/osu.Game.Rulesets.Pippidon.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + osu.Game.Rulesets.Sample + Library + AnyCPU + osu.Game.Rulesets.Pippidon + + + + + + + + \ No newline at end of file diff --git a/Templates/osu.Game.Templates.csproj b/Templates/osu.Game.Templates.csproj new file mode 100644 index 0000000000..31a24a301f --- /dev/null +++ b/Templates/osu.Game.Templates.csproj @@ -0,0 +1,24 @@ + + + Template + ppy.osu.Game.Templates + osu! templates + ppy Pty Ltd + https://github.com/ppy/osu/blob/master/LICENCE + https://github.com/ppy/osu/blob/master/Templates + https://github.com/ppy/osu + Automated release. + Copyright (c) 2021 ppy Pty Ltd + Templates to use when creating a ruleset for consumption in osu!. + dotnet-new;templates;osu + netstandard2.1 + true + false + content + + + + + + + diff --git a/appveyor_deploy.yml b/appveyor_deploy.yml index 737e5c43ab..adf98848bc 100644 --- a/appveyor_deploy.yml +++ b/appveyor_deploy.yml @@ -16,6 +16,8 @@ environment: job_depends_on: osu-game - job_name: mania-ruleset job_depends_on: osu-game + - job_name: templates + job_depends_on: osu-game nuget: project_feed: true @@ -59,6 +61,22 @@ for: - cmd: dotnet remove osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj reference osu.Game\osu.Game.csproj - cmd: dotnet add osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% - cmd: dotnet pack osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% + - + matrix: + only: + - job_name: templates + build_script: + - cmd: dotnet remove Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet remove Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj reference osu.Game\osu.Game.csproj + - cmd: dotnet remove Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj reference osu.Game\osu.Game.csproj + + - cmd: dotnet add Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet add Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + - cmd: dotnet add Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj package ppy.osu.Game -v %APPVEYOR_REPO_TAG_NAME% + + - cmd: dotnet pack Templates\osu.Game.Templates.csproj /p:Version=%APPVEYOR_REPO_TAG_NAME% artifacts: - path: '**\*.nupkg' diff --git a/osu.Android.props b/osu.Android.props index bfdc8f6b3c..e95c7e6619 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,7 @@ - - + + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index ad929bbac3..cffcea22c2 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -17,7 +17,7 @@ using osu.Game.Database; namespace osu.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false, LaunchMode = LaunchMode.SingleInstance)] + [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false, LaunchMode = LaunchMode.SingleInstance, Exported = true)] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] { "application/zip", "application/octet-stream", "application/download", "application/x-zip", "application/x-zip-compressed" })] @@ -100,15 +100,15 @@ namespace osu.Android // copy to an arbitrary-access memory stream to be able to proceed with the import. var copy = new MemoryStream(); using (var stream = ContentResolver.OpenInputStream(uri)) - await stream.CopyToAsync(copy); + await stream.CopyToAsync(copy).ConfigureAwait(false); lock (tasks) { tasks.Add(new ImportTask(copy, filename)); } - })); + })).ConfigureAwait(false); - await game.Import(tasks.ToArray()); + await game.Import(tasks.ToArray()).ConfigureAwait(false); }, TaskCreationOptions.LongRunning); } } diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 21d6336b2c..050bf2b787 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -7,6 +7,8 @@ using Android.OS; using osu.Framework.Allocation; using osu.Game; using osu.Game.Updater; +using osu.Game.Utils; +using Xamarin.Essentials; namespace osu.Android { @@ -72,5 +74,14 @@ namespace osu.Android } protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager(); + + protected override BatteryInfo CreateBatteryInfo() => new AndroidBatteryInfo(); + + private class AndroidBatteryInfo : BatteryInfo + { + public override double ChargeLevel => Battery.ChargeLevel; + + public override bool IsCharging => Battery.PowerSource != BatteryPowerSource.Battery; + } } } diff --git a/osu.Android/Properties/AndroidManifest.xml b/osu.Android/Properties/AndroidManifest.xml index 770eaf2222..e717bab310 100644 --- a/osu.Android/Properties/AndroidManifest.xml +++ b/osu.Android/Properties/AndroidManifest.xml @@ -6,5 +6,6 @@ + \ No newline at end of file diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index a2638e95c8..582c856a47 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -20,6 +20,11 @@ d8 r8 + + None + cjk;mideast;other;rare;west + true + @@ -53,5 +58,13 @@ + + + 5.0.0 + + + + + \ No newline at end of file diff --git a/osu.Desktop.slnf b/osu.Desktop.slnf index d2c14d321a..503e5935f5 100644 --- a/osu.Desktop.slnf +++ b/osu.Desktop.slnf @@ -15,7 +15,16 @@ "osu.Game.Tests\\osu.Game.Tests.csproj", "osu.Game.Tournament.Tests\\osu.Game.Tournament.Tests.csproj", "osu.Game.Tournament\\osu.Game.Tournament.csproj", - "osu.Game\\osu.Game.csproj" + "osu.Game\\osu.Game.csproj", + + "Templates\\Rulesets\\ruleset-empty\\osu.Game.Rulesets.EmptyFreeform\\osu.Game.Rulesets.EmptyFreeform.csproj", + "Templates\\Rulesets\\ruleset-empty\\osu.Game.Rulesets.EmptyFreeform.Tests\\osu.Game.Rulesets.EmptyFreeform.Tests.csproj", + "Templates\\Rulesets\\ruleset-example\\osu.Game.Rulesets.Pippidon\\osu.Game.Rulesets.Pippidon.csproj", + "Templates\\Rulesets\\ruleset-example\\osu.Game.Rulesets.Pippidon.Tests\\osu.Game.Rulesets.Pippidon.Tests.csproj", + "Templates\\Rulesets\\ruleset-scrolling-empty\\osu.Game.Rulesets.EmptyScrolling\\osu.Game.Rulesets.EmptyScrolling.csproj", + "Templates\\Rulesets\\ruleset-scrolling-empty\\osu.Game.Rulesets.EmptyScrolling.Tests\\osu.Game.Rulesets.EmptyScrolling.Tests.csproj", + "Templates\\Rulesets\\ruleset-scrolling-example\\osu.Game.Rulesets.Pippidon\\osu.Game.Rulesets.Pippidon.csproj", + "Templates\\Rulesets\\ruleset-scrolling-example\\osu.Game.Rulesets.Pippidon.Tests\\osu.Game.Rulesets.Pippidon.Tests.csproj" ] } -} \ No newline at end of file +} diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 5909b82c8f..4de1e84fbf 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -2,12 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.Versioning; using System.Threading.Tasks; using Microsoft.Win32; +using osu.Desktop.Security; using osu.Desktop.Overlays; using osu.Framework.Platform; using osu.Game; @@ -18,6 +20,7 @@ using osu.Framework.Screens; using osu.Game.Screens.Menu; using osu.Game.Updater; using osu.Desktop.Windows; +using osu.Framework.Threading; using osu.Game.IO; namespace osu.Desktop @@ -54,7 +57,7 @@ namespace osu.Desktop private string getStableInstallPath() { - static bool checkExists(string p) => Directory.Exists(Path.Combine(p, "Songs")); + static bool checkExists(string p) => Directory.Exists(Path.Combine(p, "Songs")) || File.Exists(Path.Combine(p, "osu!.cfg")); string stableInstallPath; @@ -111,6 +114,8 @@ namespace osu.Desktop if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) LoadComponentAsync(new GameplayWinKeyBlocker(), Add); + + LoadComponentAsync(new ElevatedPrivilegesChecker(), Add); } protected override void ScreenChanged(IScreen lastScreen, IScreen newScreen) @@ -136,33 +141,47 @@ namespace osu.Desktop var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); - switch (host.Window) - { - // Legacy osuTK DesktopGameWindow - case OsuTKDesktopWindow desktopGameWindow: - desktopGameWindow.CursorState |= CursorState.Hidden; - desktopGameWindow.SetIconFromStream(iconStream); - desktopGameWindow.Title = Name; - desktopGameWindow.FileDrop += (_, e) => fileDrop(e.FileNames); - break; + var desktopWindow = (SDL2DesktopWindow)host.Window; - // SDL2 DesktopWindow - case SDL2DesktopWindow desktopWindow: - desktopWindow.CursorState |= CursorState.Hidden; - desktopWindow.SetIconFromStream(iconStream); - desktopWindow.Title = Name; - desktopWindow.DragDrop += f => fileDrop(new[] { f }); - break; - } + desktopWindow.CursorState |= CursorState.Hidden; + desktopWindow.SetIconFromStream(iconStream); + desktopWindow.Title = Name; + desktopWindow.DragDrop += f => fileDrop(new[] { f }); } + private readonly List importableFiles = new List(); + private ScheduledDelegate importSchedule; + private void fileDrop(string[] filePaths) { - var firstExtension = Path.GetExtension(filePaths.First()); + lock (importableFiles) + { + var firstExtension = Path.GetExtension(filePaths.First()); - if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return; + if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return; - Task.Factory.StartNew(() => Import(filePaths), TaskCreationOptions.LongRunning); + importableFiles.AddRange(filePaths); + + Logger.Log($"Adding {filePaths.Length} files for import"); + + // File drag drop operations can potentially trigger hundreds or thousands of these calls on some platforms. + // In order to avoid spawning multiple import tasks for a single drop operation, debounce a touch. + importSchedule?.Cancel(); + importSchedule = Scheduler.AddDelayed(handlePendingImports, 100); + } + } + + private void handlePendingImports() + { + lock (importableFiles) + { + Logger.Log($"Handling batch import of {importableFiles.Count} files"); + + var paths = importableFiles.ToArray(); + importableFiles.Clear(); + + Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning); + } } } } diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 6ca7079654..5fb09c0cef 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -22,9 +22,8 @@ namespace osu.Desktop { // Back up the cwd before DesktopGameHost changes it var cwd = Environment.CurrentDirectory; - bool useOsuTK = args.Contains("--tk"); - using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true, useOsuTK: useOsuTK)) + using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) { host.ExceptionThrown += handleException; @@ -70,7 +69,6 @@ namespace osu.Desktop /// Allow a maximum of one unhandled exception, per second of execution. /// /// - /// private static bool handleException(Exception arg) { bool continueExecution = Interlocked.Decrement(ref allowableExceptions) >= 0; diff --git a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs new file mode 100644 index 0000000000..01458b4c37 --- /dev/null +++ b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs @@ -0,0 +1,83 @@ +// 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.Security.Principal; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; + +namespace osu.Desktop.Security +{ + /// + /// Checks if the game is running with elevated privileges (as admin in Windows, root in Unix) and displays a warning notification if so. + /// + public class ElevatedPrivilegesChecker : Component + { + [Resolved] + private NotificationOverlay notifications { get; set; } + + private bool elevated; + + [BackgroundDependencyLoader] + private void load() + { + elevated = checkElevated(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (elevated) + notifications.Post(new ElevatedPrivilegesNotification()); + } + + private bool checkElevated() + { + try + { + switch (RuntimeInfo.OS) + { + case RuntimeInfo.Platform.Windows: + if (!OperatingSystem.IsWindows()) return false; + + var windowsIdentity = WindowsIdentity.GetCurrent(); + var windowsPrincipal = new WindowsPrincipal(windowsIdentity); + + return windowsPrincipal.IsInRole(WindowsBuiltInRole.Administrator); + + case RuntimeInfo.Platform.macOS: + case RuntimeInfo.Platform.Linux: + return Mono.Unix.Native.Syscall.geteuid() == 0; + } + } + catch + { + } + + return false; + } + + private class ElevatedPrivilegesNotification : SimpleNotification + { + public override bool IsImportant => true; + + public ElevatedPrivilegesNotification() + { + Text = $"Running osu! as {(RuntimeInfo.IsUnix ? "root" : "administrator")} does not improve performance, may break integrations and poses a security risk. Please run the game as a normal user."; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, NotificationOverlay notificationOverlay) + { + Icon = FontAwesome.Solid.ShieldAlt; + IconBackgound.Colour = colours.YellowDark; + } + } + } +} diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 71f9fafe57..47cd39dc5a 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -42,7 +42,7 @@ namespace osu.Desktop.Updater Splat.Locator.CurrentMutable.Register(() => new SquirrelLogger(), typeof(Splat.ILogger)); } - protected override async Task PerformUpdateCheck() => await checkForUpdateAsync(); + protected override async Task PerformUpdateCheck() => await checkForUpdateAsync().ConfigureAwait(false); private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) { @@ -51,9 +51,9 @@ namespace osu.Desktop.Updater try { - updateManager ??= await UpdateManager.GitHubUpdateManager(@"https://github.com/ppy/osu", @"osulazer", null, null, true); + updateManager ??= await UpdateManager.GitHubUpdateManager(@"https://github.com/ppy/osu", @"osulazer", null, null, true).ConfigureAwait(false); - var info = await updateManager.CheckForUpdate(!useDeltaPatching); + var info = await updateManager.CheckForUpdate(!useDeltaPatching).ConfigureAwait(false); if (info.ReleasesToApply.Count == 0) { @@ -79,12 +79,12 @@ namespace osu.Desktop.Updater try { - await updateManager.DownloadReleases(info.ReleasesToApply, p => notification.Progress = p / 100f); + await updateManager.DownloadReleases(info.ReleasesToApply, p => notification.Progress = p / 100f).ConfigureAwait(false); notification.Progress = 0; notification.Text = @"Installing update..."; - await updateManager.ApplyReleases(info, p => notification.Progress = p / 100f); + await updateManager.ApplyReleases(info, p => notification.Progress = p / 100f).ConfigureAwait(false); notification.State = ProgressNotificationState.Completed; updatePending = true; @@ -97,7 +97,7 @@ namespace osu.Desktop.Updater // could fail if deltas are unavailable for full update path (https://github.com/Squirrel/Squirrel.Windows/issues/959) // try again without deltas. - await checkForUpdateAsync(false, notification); + await checkForUpdateAsync(false, notification).ConfigureAwait(false); scheduleRecheck = false; } else @@ -116,7 +116,7 @@ namespace osu.Desktop.Updater if (scheduleRecheck) { // check again in 30 minutes. - Scheduler.AddDelayed(async () => await checkForUpdateAsync(), 60000 * 30); + Scheduler.AddDelayed(async () => await checkForUpdateAsync().ConfigureAwait(false), 60000 * 30); } } diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index 3e0f0cb7f6..ad5c323e9b 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -25,6 +25,7 @@ + diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index ea43d9a54c..7a74563b2b 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/osu.Game.Rulesets.Catch.Tests.Android/osu.Game.Rulesets.Catch.Tests.Android.csproj b/osu.Game.Rulesets.Catch.Tests.Android/osu.Game.Rulesets.Catch.Tests.Android.csproj index 88b420ffad..94fdba4a3e 100644 --- a/osu.Game.Rulesets.Catch.Tests.Android/osu.Game.Rulesets.Catch.Tests.Android.csproj +++ b/osu.Game.Rulesets.Catch.Tests.Android/osu.Game.Rulesets.Catch.Tests.Android.csproj @@ -14,6 +14,11 @@ Properties\AndroidManifest.xml armeabi-v7a;x86;arm64-v8a + + None + cjk;mideast;other;rare;west + true + @@ -35,5 +40,10 @@ osu.Game + + + 5.0.0 + + \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index f4ee3f5a42..5580358f89 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.Tests public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(5.0565038923984691d, "diffcalc-test")] + [TestCase(5.169743871843191d, "diffcalc-test")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new CatchModDoubleTime()); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs index f15da29993..1248409b2a 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Tests [BackgroundDependencyLoader] private void load() { - LocalConfig.Set(OsuSetting.IncreaseFirstObjectVisibility, false); + LocalConfig.SetValue(OsuSetting.IncreaseFirstObjectVisibility, false); } [Test] diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs index 64695153b5..b7cd6737b1 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayerLegacySkin.cs @@ -1,8 +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.Linq; using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; using osu.Game.Tests.Visual; +using osuTK; namespace osu.Game.Rulesets.Catch.Tests { @@ -10,5 +18,22 @@ namespace osu.Game.Rulesets.Catch.Tests public class TestSceneCatchPlayerLegacySkin : LegacySkinPlayerTestScene { protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + + [Test] + public void TestLegacyHUDComboCounterHidden([Values] bool withModifiedSkin) + { + if (withModifiedSkin) + { + AddStep("change component scale", () => Player.ChildrenOfType().First().Scale = new Vector2(2f)); + AddStep("update target", () => Player.ChildrenOfType().ForEach(LegacySkin.UpdateDrawableTarget)); + AddStep("exit player", () => Player.Exit()); + CreateTest(null); + } + + AddAssert("legacy HUD combo counter hidden", () => + { + return Player.ChildrenOfType().All(c => c.ChildrenOfType().Single().Alpha == 0f); + }); + } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs new file mode 100644 index 0000000000..a10371b0f7 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs @@ -0,0 +1,53 @@ +// 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.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneCatchReplay : TestSceneCatchPlayer + { + protected override bool Autoplay => true; + + private const int object_count = 10; + + [Test] + public void TestReplayCatcherPositionIsFramePerfect() + { + AddUntilStep("caught all fruits", () => Player.ScoreProcessor.Combo.Value == object_count); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new Beatmap + { + BeatmapInfo = + { + Ruleset = ruleset, + } + }; + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint()); + + for (int i = 0; i < object_count / 2; i++) + { + beatmap.HitObjects.Add(new Fruit + { + StartTime = (i + 1) * 1000, + X = 0 + }); + beatmap.HitObjects.Add(new Fruit + { + StartTime = (i + 1) * 1000 + 1, + X = CatchPlayfield.WIDTH + }); + } + + return beatmap; + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index e8bb57cdf3..517027a9fc 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -10,6 +10,7 @@ using osu.Game.Rulesets.Catch.UI; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; @@ -20,6 +21,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osu.Game.Tests.Visual; +using osuTK; namespace osu.Game.Rulesets.Catch.Tests { @@ -170,16 +172,25 @@ namespace osu.Game.Rulesets.Catch.Tests } [Test] - public void TestCatcherStacking() + public void TestCatcherRandomStacking() + { + AddStep("catch more fruits", () => attemptCatch(() => new Fruit + { + X = (RNG.NextSingle() - 0.5f) * Catcher.CalculateCatchWidth(Vector2.One) + }, 50)); + } + + [Test] + public void TestCatcherStackingSameCaughtPosition() { AddStep("catch fruit", () => attemptCatch(new Fruit())); checkPlate(1); - AddStep("catch more fruits", () => attemptCatch(new Fruit(), 9)); + AddStep("catch more fruits", () => attemptCatch(() => new Fruit(), 9)); checkPlate(10); AddAssert("caught objects are stacked", () => - catcher.CaughtObjects.All(obj => obj.Y <= 0) && - catcher.CaughtObjects.Any(obj => obj.Y == 0) && - catcher.CaughtObjects.Any(obj => obj.Y < -20)); + catcher.CaughtObjects.All(obj => obj.Y <= Catcher.CAUGHT_FRUIT_VERTICAL_OFFSET) && + catcher.CaughtObjects.Any(obj => obj.Y == Catcher.CAUGHT_FRUIT_VERTICAL_OFFSET) && + catcher.CaughtObjects.Any(obj => obj.Y < -25)); } [Test] @@ -189,11 +200,11 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet())); AddAssert("tiny droplet is exploded", () => catcher.CaughtObjects.Count() == 1 && droppedObjectContainer.Count == 1); AddUntilStep("wait explosion", () => !droppedObjectContainer.Any()); - AddStep("catch more fruits", () => attemptCatch(new Fruit(), 9)); + AddStep("catch more fruits", () => attemptCatch(() => new Fruit(), 9)); AddStep("explode", () => catcher.Explode()); AddAssert("fruits are exploded", () => !catcher.CaughtObjects.Any() && droppedObjectContainer.Count == 10); AddUntilStep("wait explosion", () => !droppedObjectContainer.Any()); - AddStep("catch fruits", () => attemptCatch(new Fruit(), 10)); + AddStep("catch fruits", () => attemptCatch(() => new Fruit(), 10)); AddStep("drop", () => catcher.Drop()); AddAssert("fruits are dropped", () => !catcher.CaughtObjects.Any() && droppedObjectContainer.Count == 10); } @@ -202,7 +213,7 @@ namespace osu.Game.Rulesets.Catch.Tests public void TestHitLightingColour() { var fruitColour = SkinConfiguration.DefaultComboColours[1]; - AddStep("enable hit lighting", () => config.Set(OsuSetting.HitLighting, true)); + AddStep("enable hit lighting", () => config.SetValue(OsuSetting.HitLighting, true)); AddStep("catch fruit", () => attemptCatch(new Fruit())); AddAssert("correct hit lighting colour", () => catcher.ChildrenOfType().First()?.ObjectColour == fruitColour); @@ -211,7 +222,7 @@ namespace osu.Game.Rulesets.Catch.Tests [Test] public void TestHitLightingDisabled() { - AddStep("disable hit lighting", () => config.Set(OsuSetting.HitLighting, false)); + AddStep("disable hit lighting", () => config.SetValue(OsuSetting.HitLighting, false)); AddStep("catch fruit", () => attemptCatch(new Fruit())); AddAssert("no hit lighting", () => !catcher.ChildrenOfType().Any()); } @@ -222,10 +233,15 @@ namespace osu.Game.Rulesets.Catch.Tests private void checkHyperDash(bool state) => AddAssert($"catcher is {(state ? "" : "not ")}hyper dashing", () => catcher.HyperDashing == state); - private void attemptCatch(CatchHitObject hitObject, int count = 1) + private void attemptCatch(CatchHitObject hitObject) + { + attemptCatch(() => hitObject, 1); + } + + private void attemptCatch(Func hitObject, int count) { for (var i = 0; i < count; i++) - attemptCatch(hitObject, out _, out _); + attemptCatch(hitObject(), out _, out _); } private void attemptCatch(CatchHitObject hitObject, out DrawableCatchHitObject drawableObject, out JudgementResult result) diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 1cbfa6338e..4af5098451 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -8,6 +8,8 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; @@ -31,12 +33,32 @@ namespace osu.Game.Rulesets.Catch.Tests private float circleSize; + private ScheduledDelegate addManyFruit; + + private BeatmapDifficulty beatmapDifficulty; + public TestSceneCatcherArea() { AddSliderStep("circle size", 0, 8, 5, createCatcher); AddToggleStep("hyper dash", t => this.ChildrenOfType().ForEach(area => area.ToggleHyperDash(t))); - AddStep("catch fruit", () => attemptCatch(new Fruit())); + AddStep("catch centered fruit", () => attemptCatch(new Fruit())); + AddStep("catch many random fruit", () => + { + int count = 50; + + addManyFruit?.Cancel(); + addManyFruit = Scheduler.AddDelayed(() => + { + attemptCatch(new Fruit + { + X = (RNG.NextSingle() - 0.5f) * Catcher.CalculateCatchWidth(beatmapDifficulty) * 0.6f, + }); + + if (count-- == 0) + addManyFruit?.Cancel(); + }, 50, true); + }); AddStep("catch fruit last in combo", () => attemptCatch(new Fruit { LastInCombo = true })); AddStep("catch kiai fruit", () => attemptCatch(new TestSceneCatcher.TestKiaiFruit())); AddStep("miss last in combo", () => attemptCatch(new Fruit { X = 100, LastInCombo = true })); @@ -45,10 +67,7 @@ namespace osu.Game.Rulesets.Catch.Tests private void attemptCatch(Fruit fruit) { fruit.X = fruit.OriginalX + catcher.X; - fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty - { - CircleSize = circleSize - }); + fruit.ApplyDefaults(new ControlPointInfo(), beatmapDifficulty); foreach (var area in this.ChildrenOfType()) { @@ -71,7 +90,12 @@ namespace osu.Game.Rulesets.Catch.Tests { circleSize = size; - SetContents(() => + beatmapDifficulty = new BeatmapDifficulty + { + CircleSize = circleSize + }; + + SetContents(_ => { var droppedObjectContainer = new Container { @@ -84,7 +108,7 @@ namespace osu.Game.Rulesets.Catch.Tests Children = new Drawable[] { droppedObjectContainer, - new TestCatcherArea(droppedObjectContainer, new BeatmapDifficulty { CircleSize = size }) + new TestCatcherArea(droppedObjectContainer, beatmapDifficulty) { Anchor = Anchor.Centre, Origin = Anchor.TopCentre, diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs index c7b322c8a0..064a84cb98 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Catch.Tests { scoreProcessor = new ScoreProcessor(); - SetContents(() => new CatchComboDisplay + SetContents(_ => new CatchComboDisplay { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs index 3a651605d3..943adbef52 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs @@ -19,22 +19,22 @@ namespace osu.Game.Rulesets.Catch.Tests { base.LoadComplete(); - AddStep("show pear", () => SetContents(() => createDrawableFruit(0))); - AddStep("show grape", () => SetContents(() => createDrawableFruit(1))); - AddStep("show pineapple / apple", () => SetContents(() => createDrawableFruit(2))); - AddStep("show raspberry / orange", () => SetContents(() => createDrawableFruit(3))); + AddStep("show pear", () => SetContents(_ => createDrawableFruit(0))); + AddStep("show grape", () => SetContents(_ => createDrawableFruit(1))); + AddStep("show pineapple / apple", () => SetContents(_ => createDrawableFruit(2))); + AddStep("show raspberry / orange", () => SetContents(_ => createDrawableFruit(3))); - AddStep("show banana", () => SetContents(createDrawableBanana)); + AddStep("show banana", () => SetContents(_ => createDrawableBanana())); - AddStep("show droplet", () => SetContents(() => createDrawableDroplet())); - AddStep("show tiny droplet", () => SetContents(createDrawableTinyDroplet)); + AddStep("show droplet", () => SetContents(_ => createDrawableDroplet())); + AddStep("show tiny droplet", () => SetContents(_ => createDrawableTinyDroplet())); - AddStep("show hyperdash pear", () => SetContents(() => createDrawableFruit(0, true))); - AddStep("show hyperdash grape", () => SetContents(() => createDrawableFruit(1, true))); - AddStep("show hyperdash pineapple / apple", () => SetContents(() => createDrawableFruit(2, true))); - AddStep("show hyperdash raspberry / orange", () => SetContents(() => createDrawableFruit(3, true))); + AddStep("show hyperdash pear", () => SetContents(_ => createDrawableFruit(0, true))); + AddStep("show hyperdash grape", () => SetContents(_ => createDrawableFruit(1, true))); + AddStep("show hyperdash pineapple / apple", () => SetContents(_ => createDrawableFruit(2, true))); + AddStep("show hyperdash raspberry / orange", () => SetContents(_ => createDrawableFruit(3, true))); - AddStep("show hyperdash droplet", () => SetContents(() => createDrawableDroplet(true))); + AddStep("show hyperdash droplet", () => SetContents(_ => createDrawableDroplet(true))); } private Drawable createDrawableFruit(int indexInBeatmap, bool hyperdash = false) => diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs index 125e0c674c..9446e864a1 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs @@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Catch.Tests protected override void LoadComplete() { - AddStep("fruit changes visual and hyper", () => SetContents(() => new TestDrawableCatchHitObjectSpecimen(new DrawableFruit(new Fruit + AddStep("fruit changes visual and hyper", () => SetContents(_ => new TestDrawableCatchHitObjectSpecimen(new DrawableFruit(new Fruit { IndexInBeatmapBindable = { BindTarget = indexInBeatmap }, HyperDashBindable = { BindTarget = hyperDash }, })))); - AddStep("droplet changes hyper", () => SetContents(() => new TestDrawableCatchHitObjectSpecimen(new DrawableDroplet(new Droplet + AddStep("droplet changes hyper", () => SetContents(_ => new TestDrawableCatchHitObjectSpecimen(new DrawableDroplet(new Droplet { HyperDashBindable = { BindTarget = hyperDash }, })))); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneLegacyBeatmapSkin.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneLegacyBeatmapSkin.cs index eea83ef7c1..bc3daca16f 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneLegacyBeatmapSkin.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneLegacyBeatmapSkin.cs @@ -32,28 +32,28 @@ namespace osu.Game.Rulesets.Catch.Tests [TestCase(true, false)] [TestCase(false, true)] [TestCase(false, false)] - public override void TestBeatmapComboColours(bool userHasCustomColours, bool useBeatmapSkin) + public void TestBeatmapComboColours(bool userHasCustomColours, bool useBeatmapSkin) { - TestBeatmap = new CatchCustomSkinWorkingBeatmap(audio, true); - base.TestBeatmapComboColours(userHasCustomColours, useBeatmapSkin); + PrepareBeatmap(() => new CatchCustomSkinWorkingBeatmap(audio, true)); + ConfigureTest(useBeatmapSkin, true, userHasCustomColours); AddAssert("is beatmap skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(TestBeatmapSkin.Colours)); } [TestCase(true)] [TestCase(false)] - public override void TestBeatmapComboColoursOverride(bool useBeatmapSkin) + public void TestBeatmapComboColoursOverride(bool useBeatmapSkin) { - TestBeatmap = new CatchCustomSkinWorkingBeatmap(audio, true); - base.TestBeatmapComboColoursOverride(useBeatmapSkin); + PrepareBeatmap(() => new CatchCustomSkinWorkingBeatmap(audio, true)); + ConfigureTest(useBeatmapSkin, false, true); AddAssert("is user custom skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(TestSkin.Colours)); } [TestCase(true)] [TestCase(false)] - public override void TestBeatmapComboColoursOverrideWithDefaultColours(bool useBeatmapSkin) + public void TestBeatmapComboColoursOverrideWithDefaultColours(bool useBeatmapSkin) { - TestBeatmap = new CatchCustomSkinWorkingBeatmap(audio, true); - base.TestBeatmapComboColoursOverrideWithDefaultColours(useBeatmapSkin); + PrepareBeatmap(() => new CatchCustomSkinWorkingBeatmap(audio, true)); + ConfigureTest(useBeatmapSkin, false, false); AddAssert("is default user skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(SkinConfiguration.DefaultComboColours)); } @@ -61,10 +61,10 @@ namespace osu.Game.Rulesets.Catch.Tests [TestCase(false, true)] [TestCase(true, false)] [TestCase(false, false)] - public override void TestBeatmapNoComboColours(bool useBeatmapSkin, bool useBeatmapColour) + public void TestBeatmapNoComboColours(bool useBeatmapSkin, bool useBeatmapColour) { - TestBeatmap = new CatchCustomSkinWorkingBeatmap(audio, false); - base.TestBeatmapNoComboColours(useBeatmapSkin, useBeatmapColour); + PrepareBeatmap(() => new CatchCustomSkinWorkingBeatmap(audio, false)); + ConfigureTest(useBeatmapSkin, useBeatmapColour, false); AddAssert("is default user skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(SkinConfiguration.DefaultComboColours)); } @@ -72,10 +72,10 @@ namespace osu.Game.Rulesets.Catch.Tests [TestCase(false, true)] [TestCase(true, false)] [TestCase(false, false)] - public override void TestBeatmapNoComboColoursSkinOverride(bool useBeatmapSkin, bool useBeatmapColour) + public void TestBeatmapNoComboColoursSkinOverride(bool useBeatmapSkin, bool useBeatmapColour) { - TestBeatmap = new CatchCustomSkinWorkingBeatmap(audio, false); - base.TestBeatmapNoComboColoursSkinOverride(useBeatmapSkin, useBeatmapColour); + PrepareBeatmap(() => new CatchCustomSkinWorkingBeatmap(audio, false)); + ConfigureTest(useBeatmapSkin, useBeatmapColour, true); AddAssert("is custom user skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(TestSkin.Colours)); } @@ -83,7 +83,7 @@ namespace osu.Game.Rulesets.Catch.Tests [TestCase(false)] public void TestBeatmapHyperDashColours(bool useBeatmapSkin) { - TestBeatmap = new CatchCustomSkinWorkingBeatmap(audio, true); + PrepareBeatmap(() => new CatchCustomSkinWorkingBeatmap(audio, true)); ConfigureTest(useBeatmapSkin, true, true); AddAssert("is custom hyper dash colours", () => ((CatchExposedPlayer)TestPlayer).UsableHyperDashColour == TestBeatmapSkin.HYPER_DASH_COLOUR); AddAssert("is custom hyper dash after image colours", () => ((CatchExposedPlayer)TestPlayer).UsableHyperDashAfterImageColour == TestBeatmapSkin.HYPER_DASH_AFTER_IMAGE_COLOUR); @@ -94,7 +94,7 @@ namespace osu.Game.Rulesets.Catch.Tests [TestCase(false)] public void TestBeatmapHyperDashColoursOverride(bool useBeatmapSkin) { - TestBeatmap = new CatchCustomSkinWorkingBeatmap(audio, true); + PrepareBeatmap(() => new CatchCustomSkinWorkingBeatmap(audio, true)); ConfigureTest(useBeatmapSkin, false, true); AddAssert("is custom hyper dash colours", () => ((CatchExposedPlayer)TestPlayer).UsableHyperDashColour == TestSkin.HYPER_DASH_COLOUR); AddAssert("is custom hyper dash after image colours", () => ((CatchExposedPlayer)TestPlayer).UsableHyperDashAfterImageColour == TestSkin.HYPER_DASH_AFTER_IMAGE_COLOUR); diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj index bf3aba5859..83d0744588 100644 --- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj +++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj @@ -2,8 +2,8 @@ - - + + diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 0a817eca0d..23ce444560 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using System; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Catch.Skinning.Legacy; using osu.Game.Skinning; @@ -50,40 +51,40 @@ namespace osu.Game.Rulesets.Catch public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlag(LegacyMods.Nightcore)) + if (mods.HasFlagFast(LegacyMods.Nightcore)) yield return new CatchModNightcore(); - else if (mods.HasFlag(LegacyMods.DoubleTime)) + else if (mods.HasFlagFast(LegacyMods.DoubleTime)) yield return new CatchModDoubleTime(); - if (mods.HasFlag(LegacyMods.Perfect)) + if (mods.HasFlagFast(LegacyMods.Perfect)) yield return new CatchModPerfect(); - else if (mods.HasFlag(LegacyMods.SuddenDeath)) + else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) yield return new CatchModSuddenDeath(); - if (mods.HasFlag(LegacyMods.Cinema)) + if (mods.HasFlagFast(LegacyMods.Cinema)) yield return new CatchModCinema(); - else if (mods.HasFlag(LegacyMods.Autoplay)) + else if (mods.HasFlagFast(LegacyMods.Autoplay)) yield return new CatchModAutoplay(); - if (mods.HasFlag(LegacyMods.Easy)) + if (mods.HasFlagFast(LegacyMods.Easy)) yield return new CatchModEasy(); - if (mods.HasFlag(LegacyMods.Flashlight)) + if (mods.HasFlagFast(LegacyMods.Flashlight)) yield return new CatchModFlashlight(); - if (mods.HasFlag(LegacyMods.HalfTime)) + if (mods.HasFlagFast(LegacyMods.HalfTime)) yield return new CatchModHalfTime(); - if (mods.HasFlag(LegacyMods.HardRock)) + if (mods.HasFlagFast(LegacyMods.HardRock)) yield return new CatchModHardRock(); - if (mods.HasFlag(LegacyMods.Hidden)) + if (mods.HasFlagFast(LegacyMods.Hidden)) yield return new CatchModHidden(); - if (mods.HasFlag(LegacyMods.NoFail)) + if (mods.HasFlagFast(LegacyMods.NoFail)) yield return new CatchModNoFail(); - if (mods.HasFlag(LegacyMods.Relax)) + if (mods.HasFlagFast(LegacyMods.Relax)) yield return new CatchModRelax(); } @@ -113,6 +114,7 @@ namespace osu.Game.Rulesets.Catch return new Mod[] { new CatchModDifficultyAdjust(), + new CatchModClassic(), }; case ModType.Automation: @@ -125,7 +127,8 @@ namespace osu.Game.Rulesets.Catch case ModType.Fun: return new Mod[] { - new MultiMod(new ModWindUp(), new ModWindDown()) + new MultiMod(new ModWindUp(), new ModWindDown()), + new CatchModFloatingFruits() }; default: @@ -158,13 +161,13 @@ namespace osu.Game.Rulesets.Catch switch (result) { case HitResult.LargeTickHit: - return "large droplet"; + return "Large droplet"; case HitResult.SmallTickHit: - return "small droplet"; + return "Small droplet"; case HitResult.LargeBonus: - return "banana"; + return "Banana"; } return base.GetDisplayNameForHitResult(result); diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 10aae70722..f5cce47186 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty { private const double star_scaling_factor = 0.153; - protected override int SectionLength => 750; - private float halfCatcherWidth; public CatchDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index 83db9216ed..849af75228 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Difficulty.Skills { - public class Movement : Skill + public class Movement : StrainSkill { private const float absolute_player_positioning_error = 16f; private const float normalized_hitobject_radius = 41.0f; @@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills protected override double DecayWeight => 0.94; + protected override int SectionLength => 750; + protected readonly float HalfCatcherWidth; private float? lastPlayerPosition; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModClassic.cs b/osu.Game.Rulesets.Catch/Mods/CatchModClassic.cs new file mode 100644 index 0000000000..9624e84018 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Mods/CatchModClassic.cs @@ -0,0 +1,11 @@ +// 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.Mods; + +namespace osu.Game.Rulesets.Catch.Mods +{ + public class CatchModClassic : ModClassic + { + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs new file mode 100644 index 0000000000..63203dd57c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs @@ -0,0 +1,29 @@ +// 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.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Mods +{ + public class CatchModFloatingFruits : Mod, IApplicableToDrawableRuleset + { + public override string Name => "Floating Fruits"; + public override string Acronym => "FF"; + public override string Description => "The fruits are... floating?"; + public override double ScoreMultiplier => 1; + public override IconUsage? Icon => FontAwesome.Solid.Cloud; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableRuleset.Anchor = Anchor.Centre; + drawableRuleset.Origin = Anchor.Centre; + + drawableRuleset.Scale = new Vector2(1, -1); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs index 4b008d2734..7bad4c79cb 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs @@ -3,13 +3,16 @@ using System.Linq; using osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModHidden : ModHidden + public class CatchModHidden : ModHidden, IApplicableToDrawableRuleset { public override string Description => @"Play with fading fruits."; public override double ScoreMultiplier => 1.06; @@ -17,10 +20,20 @@ namespace osu.Game.Rulesets.Catch.Mods private const double fade_out_offset_multiplier = 0.6; private const double fade_out_duration_multiplier = 0.44; + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + var drawableCatchRuleset = (DrawableCatchRuleset)drawableRuleset; + var catchPlayfield = (CatchPlayfield)drawableCatchRuleset.Playfield; + + catchPlayfield.CatcherArea.MovableCatcher.CatchFruitOnPlate = false; + } + + protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) + { + } + protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) { - base.ApplyNormalVisibilityState(hitObject, state); - if (!(hitObject is DrawableCatchHitObject catchDrawable)) return; @@ -43,7 +56,7 @@ namespace osu.Game.Rulesets.Catch.Mods var offset = hitObject.TimePreempt * fade_out_offset_multiplier; var duration = offset - hitObject.TimePreempt * fade_out_duration_multiplier; - using (drawable.BeginAbsoluteSequence(hitObject.StartTime - offset, true)) + using (drawable.BeginAbsoluteSequence(hitObject.StartTime - offset)) drawable.FadeOut(duration); } } diff --git a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs index 64ded8e94f..a81703119a 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs @@ -5,7 +5,6 @@ using System; using System.Linq; using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Replays; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; @@ -13,26 +12,19 @@ using osu.Game.Rulesets.Replays; namespace osu.Game.Rulesets.Catch.Replays { - internal class CatchAutoGenerator : AutoGenerator + internal class CatchAutoGenerator : AutoGenerator { - public const double RELEASE_DELAY = 20; - public new CatchBeatmap Beatmap => (CatchBeatmap)base.Beatmap; public CatchAutoGenerator(IBeatmap beatmap) : base(beatmap) { - Replay = new Replay(); } - protected Replay Replay; - - private CatchReplayFrame currentFrame; - - public override Replay Generate() + protected override void GenerateFrames() { if (Beatmap.HitObjects.Count == 0) - return Replay; + return; // todo: add support for HT DT const double dash_speed = Catcher.BASE_SPEED; @@ -119,19 +111,11 @@ namespace osu.Game.Rulesets.Catch.Replays } } } - - return Replay; } private void addFrame(double time, float? position = null, bool dashing = false) { - // todo: can be removed once FramedReplayInputHandler correctly handles rewinding before first frame. - if (Replay.Frames.Count == 0) - Replay.Frames.Add(new CatchReplayFrame(time - 1, position, false, null)); - - var last = currentFrame; - currentFrame = new CatchReplayFrame(time, position, dashing, last); - Replay.Frames.Add(currentFrame); + Frames.Add(new CatchReplayFrame(time, position, dashing, LastFrame)); } } } diff --git a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs index 99d899db80..137328b1c3 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using osu.Framework.Input.StateChanges; using osu.Framework.Utils; @@ -20,29 +19,14 @@ namespace osu.Game.Rulesets.Catch.Replays protected override bool IsImportant(CatchReplayFrame frame) => frame.Actions.Any(); - protected float? Position - { - get - { - var frame = CurrentFrame; - - if (frame == null) - return null; - - Debug.Assert(CurrentTime != null); - - return NextFrame != null ? Interpolation.ValueAt(CurrentTime.Value, frame.Position, NextFrame.Position, frame.Time, NextFrame.Time) : frame.Position; - } - } - public override void CollectPendingInputs(List inputs) { - if (!Position.HasValue) return; + var position = Interpolation.ValueAt(CurrentTime, StartFrame.Position, EndFrame.Position, StartFrame.Time, EndFrame.Time); inputs.Add(new CatchReplayState { PressedActions = CurrentFrame?.Actions ?? new List(), - CatcherX = Position.Value + CatcherX = position }); } diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs index 41fd0fe776..8c9e602cd4 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs @@ -1,11 +1,12 @@ // 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.Bindables; using osu.Framework.Graphics; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osuTK.Graphics; -using static osu.Game.Skinning.LegacySkinConfiguration; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy /// /// For simplicity, let's use legacy combo font texture existence as a way to identify legacy skins from default. /// - private bool providesComboCounter => this.HasFont(GetConfig(LegacySetting.ComboPrefix)?.Value ?? "score"); + private bool providesComboCounter => this.HasFont(LegacyFont.Combo); public CatchLegacySkinTransformer(ISkinSource source) : base(source) @@ -23,60 +24,68 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy public override Drawable GetDrawableComponent(ISkinComponent component) { - if (component is HUDSkinComponent hudComponent) + if (component is SkinnableTargetComponent targetComponent) { - switch (hudComponent.Component) + switch (targetComponent.Target) { - case HUDSkinComponents.ComboCounter: - // catch may provide its own combo counter; hide the default. - return providesComboCounter ? Drawable.Empty() : null; + case SkinnableTarget.MainHUDComponents: + var components = Source.GetDrawableComponent(component) as SkinnableTargetComponentsContainer; + + if (providesComboCounter && components != null) + { + // catch may provide its own combo counter; hide the default. + // todo: this should be done in an elegant way per ruleset, defining which HUD skin components should be displayed. + foreach (var legacyComboCounter in components.OfType()) + legacyComboCounter.HiddenByRulesetImplementation = false; + } + + return components; } } - if (!(component is CatchSkinComponent catchSkinComponent)) - return null; - - switch (catchSkinComponent.Component) + if (component is CatchSkinComponent catchSkinComponent) { - case CatchSkinComponents.Fruit: - if (GetTexture("fruit-pear") != null) - return new LegacyFruitPiece(); + switch (catchSkinComponent.Component) + { + case CatchSkinComponents.Fruit: + if (GetTexture("fruit-pear") != null) + return new LegacyFruitPiece(); - break; + return null; - case CatchSkinComponents.Banana: - if (GetTexture("fruit-bananas") != null) - return new LegacyBananaPiece(); + case CatchSkinComponents.Banana: + if (GetTexture("fruit-bananas") != null) + return new LegacyBananaPiece(); - break; + return null; - case CatchSkinComponents.Droplet: - if (GetTexture("fruit-drop") != null) - return new LegacyDropletPiece(); + case CatchSkinComponents.Droplet: + if (GetTexture("fruit-drop") != null) + return new LegacyDropletPiece(); - break; + return null; - case CatchSkinComponents.CatcherIdle: - return this.GetAnimation("fruit-catcher-idle", true, true, true) ?? - this.GetAnimation("fruit-ryuuta", true, true, true); + case CatchSkinComponents.CatcherIdle: + return this.GetAnimation("fruit-catcher-idle", true, true, true) ?? + this.GetAnimation("fruit-ryuuta", true, true, true); - case CatchSkinComponents.CatcherFail: - return this.GetAnimation("fruit-catcher-fail", true, true, true) ?? - this.GetAnimation("fruit-ryuuta", true, true, true); + case CatchSkinComponents.CatcherFail: + return this.GetAnimation("fruit-catcher-fail", true, true, true) ?? + this.GetAnimation("fruit-ryuuta", true, true, true); - case CatchSkinComponents.CatcherKiai: - return this.GetAnimation("fruit-catcher-kiai", true, true, true) ?? - this.GetAnimation("fruit-ryuuta", true, true, true); + case CatchSkinComponents.CatcherKiai: + return this.GetAnimation("fruit-catcher-kiai", true, true, true) ?? + this.GetAnimation("fruit-ryuuta", true, true, true); - case CatchSkinComponents.CatchComboCounter: + case CatchSkinComponents.CatchComboCounter: + if (providesComboCounter) + return new LegacyCatchComboCounter(Source); - if (providesComboCounter) - return new LegacyCatchComboCounter(Source); - - break; + return null; + } } - return null; + return Source.GetDrawableComponent(component); } public override IBindable GetConfig(TLookup lookup) diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs index f797ae75c2..33c3867f5a 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchComboCounter.cs @@ -7,7 +7,6 @@ using osu.Game.Rulesets.Catch.UI; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; -using static osu.Game.Skinning.LegacySkinConfiguration; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { @@ -22,9 +21,6 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy public LegacyCatchComboCounter(ISkin skin) { - var fontName = skin.GetConfig(LegacySetting.ComboPrefix)?.Value ?? "score"; - var fontOverlap = skin.GetConfig(LegacySetting.ComboOverlap)?.Value ?? -2f; - AutoSizeAxes = Axes.Both; Alpha = 0f; @@ -34,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy InternalChildren = new Drawable[] { - explosion = new LegacyRollingCounter(skin, fontName, fontOverlap) + explosion = new LegacyRollingCounter(LegacyFont.Combo) { Alpha = 0.65f, Blending = BlendingParameters.Additive, @@ -42,7 +38,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy Origin = Anchor.Centre, Scale = new Vector2(1.5f), }, - counter = new LegacyRollingCounter(skin, fontName, fontOverlap) + counter = new LegacyRollingCounter(LegacyFont.Combo) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 73420a9eda..0e1ef90737 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -51,8 +51,11 @@ namespace osu.Game.Rulesets.Catch.UI { droppedObjectContainer, CatcherArea.MovableCatcher.CreateProxiedContent(), - HitObjectContainer, + HitObjectContainer.CreateProxy(), + // This ordering (`CatcherArea` before `HitObjectContainer`) is important to + // make sure the up-to-date catcher position is used for the catcher catching logic of hit objects. CatcherArea, + HitObjectContainer, }; } diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index ed875e7002..0d6a577d1e 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -43,11 +43,26 @@ namespace osu.Game.Rulesets.Catch.UI /// public bool HyperDashing => hyperDashModifier != 1; + /// + /// Whether fruit should appear on the plate. + /// + public bool CatchFruitOnPlate { get; set; } = true; + /// /// The relative space to cover in 1 millisecond. based on 1 game pixel per millisecond as in osu-stable. /// public const double BASE_SPEED = 1.0; + /// + /// The amount by which caught fruit should be offset from the plate surface to make them look visually "caught". + /// + public const float CAUGHT_FRUIT_VERTICAL_OFFSET = -5; + + /// + /// The amount by which caught fruit should be scaled down to fit on the plate. + /// + private const float caught_fruit_scale_adjust = 0.5f; + [NotNull] private readonly Container trailsTarget; @@ -197,13 +212,13 @@ namespace osu.Game.Rulesets.Catch.UI /// Calculates the width of the area used for attempting catches in gameplay. /// /// The scale of the catcher. - internal static float CalculateCatchWidth(Vector2 scale) => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; + public static float CalculateCatchWidth(Vector2 scale) => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; /// /// Calculates the width of the area used for attempting catches in gameplay. /// /// The beatmap difficulty. - internal static float CalculateCatchWidth(BeatmapDifficulty difficulty) => CalculateCatchWidth(calculateScale(difficulty)); + public static float CalculateCatchWidth(BeatmapDifficulty difficulty) => CalculateCatchWidth(calculateScale(difficulty)); /// /// Determine if this catcher can catch a in the current position. @@ -235,9 +250,10 @@ namespace osu.Game.Rulesets.Catch.UI if (result.IsHit) { - var positionInStack = computePositionInStack(new Vector2(palpableObject.X - X, 0), palpableObject.DisplaySize.X / 2); + var positionInStack = computePositionInStack(new Vector2(palpableObject.X - X, 0), palpableObject.DisplaySize.X); - placeCaughtObject(palpableObject, positionInStack); + if (CatchFruitOnPlate) + placeCaughtObject(palpableObject, positionInStack); if (hitLighting.Value) addLighting(hitObject, positionInStack.X, drawableObject.AccentColour.Value); @@ -378,16 +394,7 @@ namespace osu.Game.Rulesets.Catch.UI { updateTrailVisibility(); - if (hyperDashing) - { - this.FadeColour(hyperDashColour, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - this.FadeTo(0.2f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - } - else - { - this.FadeColour(Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - this.FadeTo(1f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - } + this.FadeColour(hyperDashing ? hyperDashColour : Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); } private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; @@ -473,7 +480,7 @@ namespace osu.Game.Rulesets.Catch.UI caughtObject.CopyStateFrom(drawableObject); caughtObject.Anchor = Anchor.TopCentre; caughtObject.Position = position; - caughtObject.Scale /= 2; + caughtObject.Scale *= caught_fruit_scale_adjust; caughtObjectContainer.Add(caughtObject); @@ -483,19 +490,21 @@ namespace osu.Game.Rulesets.Catch.UI private Vector2 computePositionInStack(Vector2 position, float displayRadius) { - const float radius_div_2 = CatchHitObject.OBJECT_RADIUS / 2; - const float allowance = 10; + // this is taken from osu-stable (lenience should be 10 * 10 at standard scale). + const float lenience_adjust = 10 / CatchHitObject.OBJECT_RADIUS; - while (caughtObjectContainer.Any(f => Vector2Extensions.Distance(f.Position, position) < (displayRadius + radius_div_2) / (allowance / 2))) + float adjustedRadius = displayRadius * lenience_adjust; + float checkDistance = MathF.Pow(adjustedRadius, 2); + + // offset fruit vertically to better place "above" the plate. + position.Y += CAUGHT_FRUIT_VERTICAL_OFFSET; + + while (caughtObjectContainer.Any(f => Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance)) { - float diff = (displayRadius + radius_div_2) / allowance; - - position.X += (RNG.NextSingle() - 0.5f) * diff * 2; - position.Y -= RNG.NextSingle() * diff; + position.X += RNG.NextSingle(-adjustedRadius, adjustedRadius); + position.Y -= RNG.NextSingle(0, 5); } - position.X = Math.Clamp(position.X, -CatcherArea.CATCHER_SIZE / 2, CatcherArea.CATCHER_SIZE / 2); - return position; } diff --git a/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj b/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj index 0e557cb260..9674186039 100644 --- a/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj +++ b/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj @@ -14,6 +14,11 @@ Properties\AndroidManifest.xml armeabi-v7a;x86;arm64-v8a + + None + cjk;mideast;other;rare;west + true + @@ -35,5 +40,10 @@ osu.Game + + + 5.0.0 + + \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs index 24f4c6858e..5e99264d7d 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs @@ -39,7 +39,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor } }; - AddBlueprint(new HoldNoteSelectionBlueprint(drawableObject)); + AddBlueprint(new HoldNoteSelectionBlueprint(holdNote), drawableObject); } protected override void Update() diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs index aaf96c63a6..8474279b01 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs @@ -184,8 +184,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor AddAssert("head note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.BottomLeft, holdNote.Head.ScreenSpaceDrawQuad.BottomLeft)); AddAssert("tail note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.TopLeft, holdNote.Tail.ScreenSpaceDrawQuad.BottomLeft)); - AddAssert("head blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(0).DrawPosition == holdNote.Head.DrawPosition); - AddAssert("tail blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(1).DrawPosition == holdNote.Tail.DrawPosition); + AddAssert("head blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(0).DrawPosition == holdNote.Head.DrawPosition); + AddAssert("tail blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(1).DrawPosition == holdNote.Tail.DrawPosition); } private void setScrollStep(ScrollingDirection direction) diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs index 36c34a8fb9..a162c5ec44 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs @@ -15,7 +15,6 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Tests.Visual; -using osuTK; using osuTK.Input; namespace osu.Game.Rulesets.Mania.Tests.Editor @@ -35,7 +34,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor [Test] public void TestPlaceBeforeCurrentTimeDownwards() { - AddStep("move mouse before current time", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single().ScreenSpaceDrawQuad.BottomLeft - new Vector2(0, 10))); + AddStep("move mouse before current time", () => + { + var column = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(column.ScreenSpacePositionAtTime(-100)); + }); AddStep("click", () => InputManager.Click(MouseButton.Left)); @@ -45,7 +48,11 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor [Test] public void TestPlaceAfterCurrentTimeDownwards() { - AddStep("move mouse after current time", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("move mouse after current time", () => + { + var column = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(column.ScreenSpacePositionAtTime(100)); + }); AddStep("click", () => InputManager.Click(MouseButton.Left)); diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs index 0e47a12a8e..9c3ad0b4ff 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor Child = drawableObject = new DrawableNote(note) }; - AddBlueprint(new NoteSelectionBlueprint(drawableObject)); + AddBlueprint(new NoteSelectionBlueprint(note), drawableObject); } } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs index 09ca04be8a..6e6500a339 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Tests public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(2.7646128945056723d, "diffcalc-test")] + [TestCase(2.7879104989252959d, "diffcalc-test")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new ManiaModDoubleTime()); diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs index 96444fd316..b7d7af6b8c 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [SetUp] public void SetUp() => Schedule(() => { - SetContents(() => new FillFlowContainer + SetContents(_ => new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs index bde323f187..106b2d188d 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(() => new FillFlowContainer + SetContents(_ => new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { RelativeSizeAxes = Axes.Both, Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, 0), _ => new DefaultColumnBackground()) + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) { RelativeSizeAxes = Axes.Both } @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { RelativeSizeAxes = Axes.Both, Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, 1), _ => new DefaultColumnBackground()) + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) { RelativeSizeAxes = Axes.Both } diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.cs index 4392666cb7..215f8fb1d5 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(() => new FillFlowContainer + SetContents(_ => new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs index dcb25f21ba..75a5495078 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { if (hitWindows.IsHitResultAllowed(result)) { - AddStep("Show " + result.GetDescription(), () => SetContents(() => + AddStep("Show " + result.GetDescription(), () => SetContents(_ => new DrawableManiaJudgement(new JudgementResult(new HitObject { StartTime = Time.Current }, new Judgement()) { Type = result diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs index 4dc6700786..004793e1e5 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(() => + SetContents(_ => { var pool = new DrawablePool(5); hitExplosionPools.Add(pool); diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs index 7e80419944..7564bd84ad 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(() => new FillFlowContainer + SetContents(_ => new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { RelativeSizeAxes = Axes.Both, Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, 0), _ => new DefaultKeyArea()) + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) { RelativeSizeAxes = Axes.Both }, @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning { RelativeSizeAxes = Axes.Both, Width = 0.5f, - Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, 1), _ => new DefaultKeyArea()) + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) { RelativeSizeAxes = Axes.Both }, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs index 161eda650e..c7dc5fc8b5 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning new StageDefinition { Columns = 2 } }; - SetContents(() => new ManiaPlayfield(stageDefinitions)); + SetContents(_ => new ManiaPlayfield(stageDefinitions)); }); } @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning new StageDefinition { Columns = 2 } }; - SetContents(() => new ManiaPlayfield(stageDefinitions)); + SetContents(_ => new ManiaPlayfield(stageDefinitions)); }); } diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs index 37b97a444a..7804261906 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(() => + SetContents(_ => { ManiaAction normalAction = ManiaAction.Key1; ManiaAction specialAction = ManiaAction.Special1; diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs index a15fb392d6..410a43fc73 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(() => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: new StageDefinition { Columns = 4 }), + SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: new StageDefinition { Columns = 4 }), _ => new DefaultStageBackground()) { Anchor = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs index bceee1c599..27e97152bc 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(() => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: new StageDefinition { Columns = 4 }), _ => null) + SetContents(_ => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: new StageDefinition { Columns = 4 }), _ => null) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs index a5248c7712..cffec3dfd5 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Tests /// /// The number of frames which are generated at the start of a replay regardless of hitobject content. /// - private const int frame_offset = 1; + private const int frame_offset = 0; [Test] public void TestSingleNote() @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == frame_offset + 2, "Replay must have 3 frames"); + Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Special1), "Special1 has not been pressed"); @@ -54,9 +54,9 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == frame_offset + 2, "Replay must have 3 frames"); + Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); - Assert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); + Assert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Special1), "Special1 has not been pressed"); Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Special1), "Special1 has not been released"); } @@ -74,7 +74,7 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == frame_offset + 2, "Replay must have 3 frames"); + Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); @@ -96,10 +96,10 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == frame_offset + 2, "Replay must have 3 frames"); + Assert.AreEqual(generated.Frames.Count, frame_offset + 2, "Incorrect number of frames"); Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect hit time"); - Assert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); + Assert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect release time"); Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been released"); @@ -119,7 +119,7 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == frame_offset + 4, "Replay must have 4 generated frames"); + Assert.AreEqual(generated.Frames.Count, frame_offset + 4, "Incorrect number of frames"); Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect first note hit time"); Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 1].Time, "Incorrect first note release time"); Assert.AreEqual(2000, generated.Frames[frame_offset + 2].Time, "Incorrect second note hit time"); @@ -146,11 +146,11 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == frame_offset + 4, "Replay must have 4 generated frames"); + Assert.AreEqual(generated.Frames.Count, frame_offset + 4, "Incorrect number of frames"); Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect first note hit time"); - Assert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 2].Time, "Incorrect first note release time"); + Assert.AreEqual(3000, generated.Frames[frame_offset + 2].Time, "Incorrect first note release time"); Assert.AreEqual(2000, generated.Frames[frame_offset + 1].Time, "Incorrect second note hit time"); - Assert.AreEqual(4000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 3].Time, "Incorrect second note release time"); + Assert.AreEqual(4000, generated.Frames[frame_offset + 3].Time, "Incorrect second note release time"); Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); Assert.IsTrue(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); Assert.IsFalse(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key1), "Key1 has not been released"); @@ -168,12 +168,12 @@ namespace osu.Game.Rulesets.Mania.Tests // | | | var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 2 }); - beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 - ManiaAutoGenerator.RELEASE_DELAY }); + beatmap.HitObjects.Add(new HoldNote { StartTime = 1000, Duration = 2000 }); beatmap.HitObjects.Add(new Note { StartTime = 3000, Column = 1 }); var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == frame_offset + 3, "Replay must have 3 generated frames"); + Assert.AreEqual(generated.Frames.Count, frame_offset + 3, "Incorrect number of frames"); Assert.AreEqual(1000, generated.Frames[frame_offset].Time, "Incorrect first note hit time"); Assert.AreEqual(3000, generated.Frames[frame_offset + 1].Time, "Incorrect second note press time + first note release time"); Assert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[frame_offset + 2].Time, "Incorrect second note release time"); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs new file mode 100644 index 0000000000..4a6c59e297 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneDrawableManiaHitObject.cs @@ -0,0 +1,67 @@ +// 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.Graphics; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestSceneDrawableManiaHitObject : OsuTestScene + { + private readonly ManualClock clock = new ManualClock(); + + private Column column; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = new ScrollingTestContainer(ScrollingDirection.Down) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + TimeRange = 2000, + Clock = new FramedClock(clock), + Child = column = new Column(0) + { + Action = { Value = ManiaAction.Key1 }, + Height = 0.85f, + AccentColour = Color4.Gray + }, + }; + }); + + [Test] + public void TestHoldNoteHeadVisibility() + { + DrawableHoldNote note = null; + AddStep("Add hold note", () => + { + var h = new HoldNote + { + StartTime = 0, + Duration = 1000 + }; + h.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + column.Add(note = new DrawableHoldNote(h)); + }); + AddStep("Hold key", () => + { + clock.CurrentTime = 0; + note.OnPressed(ManiaAction.Key1); + }); + AddStep("progress time", () => clock.CurrentTime = 500); + AddAssert("head is visible", () => note.Head.Alpha == 1); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index 596430f9e5..471dad87d5 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -286,17 +286,83 @@ namespace osu.Game.Rulesets.Mania.Tests .All(j => j.Type.IsHit())); } + [Test] + public void TestHitTailBeforeLastTick() + { + const int tick_rate = 8; + const double tick_spacing = TimingControlPoint.DEFAULT_BEAT_LENGTH / tick_rate; + const double time_last_tick = time_head + tick_spacing * (int)((time_tail - time_head) / tick_spacing - 1); + + var beatmap = new Beatmap + { + HitObjects = + { + new HoldNote + { + StartTime = time_head, + Duration = time_tail - time_head, + Column = 0, + } + }, + BeatmapInfo = + { + BaseDifficulty = new BeatmapDifficulty { SliderTickRate = tick_rate }, + Ruleset = new ManiaRuleset().RulesetInfo + }, + }; + + performTest(new List + { + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_last_tick - 5) + }, beatmap); + + assertHeadJudgement(HitResult.Perfect); + assertLastTickJudgement(HitResult.LargeTickMiss); + assertTailJudgement(HitResult.Ok); + } + + [Test] + public void TestZeroLength() + { + var beatmap = new Beatmap + { + HitObjects = + { + new HoldNote + { + StartTime = 1000, + Duration = 0, + Column = 0, + }, + }, + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo }, + }; + + performTest(new List + { + new ManiaReplayFrame(beatmap.HitObjects[0].StartTime, ManiaAction.Key1), + new ManiaReplayFrame(beatmap.HitObjects[0].GetEndTime() + 1), + }, beatmap); + + AddAssert("hold note hit", () => judgementResults.Where(j => beatmap.HitObjects[0].NestedHitObjects.Contains(j.HitObject)) + .All(j => j.Type.IsHit())); + } + private void assertHeadJudgement(HitResult result) - => AddAssert($"head judged as {result}", () => judgementResults[0].Type == result); + => AddAssert($"head judged as {result}", () => judgementResults.First(j => j.HitObject is Note).Type == result); private void assertTailJudgement(HitResult result) - => AddAssert($"tail judged as {result}", () => judgementResults[^2].Type == result); + => AddAssert($"tail judged as {result}", () => judgementResults.Single(j => j.HitObject is TailNote).Type == result); private void assertNoteJudgement(HitResult result) - => AddAssert($"hold note judged as {result}", () => judgementResults[^1].Type == result); + => AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type == result); private void assertTickJudgement(HitResult result) - => AddAssert($"tick judged as {result}", () => judgementResults[6].Type == result); // arbitrary tick + => AddAssert($"any tick judged as {result}", () => judgementResults.Where(j => j.HitObject is HoldNoteTick).Any(j => j.Type == result)); + + private void assertLastTickJudgement(HitResult result) + => AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type == result); private ScoreAccessibleReplayPlayer currentPlayer; @@ -345,13 +411,16 @@ namespace osu.Game.Rulesets.Mania.Tests AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); - AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); + + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor?.HasCompleted.Value == true); } private class ScoreAccessibleReplayPlayer : ReplayPlayer { public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; + protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs index 0d726e1a50..ea57e51d1c 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mania.Tests public class TestSceneManiaHitObjectSamples : HitObjectSampleTest { protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); - protected override IResourceStore Resources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneManiaHitObjectSamples))); + protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneManiaHitObjectSamples))); /// /// Tests that when a normal sample bank is used, the normal hitsound will be looked up. diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs index 6b8f5d5d9d..706268e478 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -97,7 +98,7 @@ namespace osu.Game.Rulesets.Mania.Tests } private bool verifyAnchors(DrawableHitObject hitObject, Anchor expectedAnchor) - => hitObject.Anchor.HasFlag(expectedAnchor) && hitObject.Origin.HasFlag(expectedAnchor); + => hitObject.Anchor.HasFlagFast(expectedAnchor) && hitObject.Origin.HasFlagFast(expectedAnchor); private bool verifyAnchors(DrawableHoldNote holdNote, Anchor expectedAnchor) => verifyAnchors((DrawableHitObject)holdNote, expectedAnchor) && holdNote.NestedHitObjects.All(n => verifyAnchors(n, expectedAnchor)); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs new file mode 100644 index 0000000000..e14ad92842 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneTimingBasedNoteColouring.cs @@ -0,0 +1,80 @@ +// 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.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Tests.Visual; +using osu.Framework.Timing; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Configuration; +using osu.Framework.Bindables; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class TestSceneTimingBasedNoteColouring : OsuTestScene + { + [Resolved] + private RulesetConfigCache configCache { get; set; } + + private readonly Bindable configTimingBasedNoteColouring = new Bindable(); + + protected override void LoadComplete() + { + const double beat_length = 500; + + var ruleset = new ManiaRuleset(); + + var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 1 }) + { + HitObjects = + { + new Note { StartTime = 0 }, + new Note { StartTime = beat_length / 16 }, + new Note { StartTime = beat_length / 12 }, + new Note { StartTime = beat_length / 8 }, + new Note { StartTime = beat_length / 6 }, + new Note { StartTime = beat_length / 4 }, + new Note { StartTime = beat_length / 3 }, + new Note { StartTime = beat_length / 2 }, + new Note { StartTime = beat_length } + }, + ControlPointInfo = new ControlPointInfo(), + BeatmapInfo = { Ruleset = ruleset.RulesetInfo }, + }; + + foreach (var note in beatmap.HitObjects) + { + note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + } + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint + { + BeatLength = beat_length + }); + + Child = new Container + { + Clock = new FramedClock(new ManualClock()), + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + ruleset.CreateDrawableRulesetWith(beatmap) + } + }; + + var config = (ManiaRulesetConfigManager)configCache.GetConfigFor(Ruleset.Value.CreateInstance()); + config.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring); + + AddStep("Enable", () => configTimingBasedNoteColouring.Value = true); + AddStep("Disable", () => configTimingBasedNoteColouring.Value = false); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj index fcc0cafefc..b2a0912d19 100644 --- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj +++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj @@ -2,8 +2,8 @@ - - + + diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index 7a0e3b2b76..26393c8edb 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps if (IsForCurrentRuleset) { - TargetColumns = (int)Math.Max(1, roundedCircleSize); + TargetColumns = GetColumnCountForNonConvert(beatmap.BeatmapInfo); if (TargetColumns > ManiaRuleset.MAX_STAGE_KEYS) { @@ -71,6 +71,12 @@ namespace osu.Game.Rulesets.Mania.Beatmaps originalTargetColumns = TargetColumns; } + public static int GetColumnCountForNonConvert(BeatmapInfo beatmap) + { + var roundedCircleSize = Math.Round(beatmap.BaseDifficulty.CircleSize); + return (int)Math.Max(1, roundedCircleSize); + } + public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition); protected override Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index 30d33de06e..26e5d381e2 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.MathUtils; @@ -141,7 +142,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 6.5) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateNRandomNotes(StartTime, 0.78, 0.3, 0); return generateNRandomNotes(StartTime, 0.85, 0.36, 0.03); @@ -149,7 +150,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 4) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateNRandomNotes(StartTime, 0.43, 0.08, 0); return generateNRandomNotes(StartTime, 0.56, 0.18, 0); @@ -157,13 +158,13 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 2.5) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateNRandomNotes(StartTime, 0.3, 0, 0); return generateNRandomNotes(StartTime, 0.37, 0.08, 0); } - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateNRandomNotes(StartTime, 0.17, 0, 0); return generateNRandomNotes(StartTime, 0.27, 0, 0); @@ -221,7 +222,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy var pattern = new Pattern(); int nextColumn = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); - if (convertType.HasFlag(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) + if (convertType.HasFlagFast(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) nextColumn = FindAvailableColumn(nextColumn, PreviousPattern); int lastColumn = nextColumn; @@ -373,7 +374,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy static bool isDoubleSample(HitSampleInfo sample) => sample.Name == HitSampleInfo.HIT_CLAP || sample.Name == HitSampleInfo.HIT_FINISH; - bool canGenerateTwoNotes = !convertType.HasFlag(PatternType.LowProbability); + bool canGenerateTwoNotes = !convertType.HasFlagFast(PatternType.LowProbability); canGenerateTwoNotes &= HitObject.Samples.Any(isDoubleSample) || sampleInfoListAt(StartTime).Any(isDoubleSample); if (canGenerateTwoNotes) @@ -406,7 +407,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy int endTime = startTime + SegmentDuration * SpanCount; int nextColumn = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); - if (convertType.HasFlag(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) + if (convertType.HasFlagFast(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) nextColumn = FindAvailableColumn(nextColumn, PreviousPattern); for (int i = 0; i < columnRepeat; i++) @@ -435,7 +436,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy var pattern = new Pattern(); int holdColumn = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); - if (convertType.HasFlag(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) + if (convertType.HasFlagFast(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) holdColumn = FindAvailableColumn(holdColumn, PreviousPattern); // Create the hold note @@ -481,7 +482,6 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// Retrieves the sample info list at a point in time. /// /// The time to retrieve the sample info list from. - /// private IList sampleInfoListAt(int time) => nodeSamplesAt(time)?.First() ?? HitObject.Samples; /// diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs index bc4ab55767..54c37e9742 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osuTK; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -78,7 +79,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy else convertType |= PatternType.LowProbability; - if (!convertType.HasFlag(PatternType.KeepSingle)) + if (!convertType.HasFlagFast(PatternType.KeepSingle)) { if (HitObject.Samples.Any(s => s.Name == HitSampleInfo.HIT_FINISH) && TotalColumns != 8) convertType |= PatternType.Mirror; @@ -101,7 +102,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy int lastColumn = PreviousPattern.HitObjects.FirstOrDefault()?.Column ?? 0; - if (convertType.HasFlag(PatternType.Reverse) && PreviousPattern.HitObjects.Any()) + if (convertType.HasFlagFast(PatternType.Reverse) && PreviousPattern.HitObjects.Any()) { // Generate a new pattern by copying the last hit objects in reverse-column order for (int i = RandomStart; i < TotalColumns; i++) @@ -113,11 +114,11 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy return pattern; } - if (convertType.HasFlag(PatternType.Cycle) && PreviousPattern.HitObjects.Count() == 1 - // If we convert to 7K + 1, let's not overload the special key - && (TotalColumns != 8 || lastColumn != 0) - // Make sure the last column was not the centre column - && (TotalColumns % 2 == 0 || lastColumn != TotalColumns / 2)) + if (convertType.HasFlagFast(PatternType.Cycle) && PreviousPattern.HitObjects.Count() == 1 + // If we convert to 7K + 1, let's not overload the special key + && (TotalColumns != 8 || lastColumn != 0) + // Make sure the last column was not the centre column + && (TotalColumns % 2 == 0 || lastColumn != TotalColumns / 2)) { // Generate a new pattern by cycling backwards (similar to Reverse but for only one hit object) int column = RandomStart + TotalColumns - lastColumn - 1; @@ -126,7 +127,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy return pattern; } - if (convertType.HasFlag(PatternType.ForceStack) && PreviousPattern.HitObjects.Any()) + if (convertType.HasFlagFast(PatternType.ForceStack) && PreviousPattern.HitObjects.Any()) { // Generate a new pattern by placing on the already filled columns for (int i = RandomStart; i < TotalColumns; i++) @@ -140,7 +141,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (PreviousPattern.HitObjects.Count() == 1) { - if (convertType.HasFlag(PatternType.Stair)) + if (convertType.HasFlagFast(PatternType.Stair)) { // Generate a new pattern by placing on the next column, cycling back to the start if there is no "next" int targetColumn = lastColumn + 1; @@ -151,7 +152,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy return pattern; } - if (convertType.HasFlag(PatternType.ReverseStair)) + if (convertType.HasFlagFast(PatternType.ReverseStair)) { // Generate a new pattern by placing on the previous column, cycling back to the end if there is no "previous" int targetColumn = lastColumn - 1; @@ -163,10 +164,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy } } - if (convertType.HasFlag(PatternType.KeepSingle)) + if (convertType.HasFlagFast(PatternType.KeepSingle)) return generateRandomNotes(1); - if (convertType.HasFlag(PatternType.Mirror)) + if (convertType.HasFlagFast(PatternType.Mirror)) { if (ConversionDifficulty > 6.5) return generateRandomPatternWithMirrored(0.12, 0.38, 0.12); @@ -178,7 +179,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 6.5) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateRandomPattern(0.78, 0.42, 0, 0); return generateRandomPattern(1, 0.62, 0, 0); @@ -186,7 +187,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 4) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateRandomPattern(0.35, 0.08, 0, 0); return generateRandomPattern(0.52, 0.15, 0, 0); @@ -194,7 +195,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (ConversionDifficulty > 2) { - if (convertType.HasFlag(PatternType.LowProbability)) + if (convertType.HasFlagFast(PatternType.LowProbability)) return generateRandomPattern(0.18, 0, 0, 0); return generateRandomPattern(0.45, 0, 0, 0); @@ -207,9 +208,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy foreach (var obj in p.HitObjects) { - if (convertType.HasFlag(PatternType.Stair) && obj.Column == TotalColumns - 1) + if (convertType.HasFlagFast(PatternType.Stair) && obj.Column == TotalColumns - 1) StairType = PatternType.ReverseStair; - if (convertType.HasFlag(PatternType.ReverseStair) && obj.Column == RandomStart) + if (convertType.HasFlagFast(PatternType.ReverseStair) && obj.Column == RandomStart) StairType = PatternType.Stair; } @@ -229,7 +230,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { var pattern = new Pattern(); - bool allowStacking = !convertType.HasFlag(PatternType.ForceNotStack); + bool allowStacking = !convertType.HasFlagFast(PatternType.ForceNotStack); if (!allowStacking) noteCount = Math.Min(noteCount, TotalColumns - RandomStart - PreviousPattern.ColumnWithObjects); @@ -249,7 +250,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy int getNextColumn(int last) { - if (convertType.HasFlag(PatternType.Gathered)) + if (convertType.HasFlagFast(PatternType.Gathered)) { last++; if (last == TotalColumns) @@ -296,7 +297,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// The containing the hit objects. private Pattern generateRandomPatternWithMirrored(double centreProbability, double p2, double p3) { - if (convertType.HasFlag(PatternType.ForceNotStack)) + if (convertType.HasFlagFast(PatternType.ForceNotStack)) return generateRandomPattern(1 / 2f + p2 / 2, p2, (p2 + p3) / 2, p3); var pattern = new Pattern(); diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index 756f2b7b2f..ac8168dfc9 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -20,8 +20,9 @@ namespace osu.Game.Rulesets.Mania.Configuration { base.InitialiseDefaults(); - Set(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5); - Set(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); + SetDefault(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5); + SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); + SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false); } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings @@ -34,6 +35,7 @@ namespace osu.Game.Rulesets.Mania.Configuration public enum ManiaRulesetSetting { ScrollTime, - ScrollDirection + ScrollDirection, + TimingBasedNoteColouring } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index d6ea58ee78..2ba2ee6b4a 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -7,11 +7,10 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Mania.Difficulty.Skills { - public class Strain : Skill + public class Strain : StrainSkill { private const double individual_decay_base = 0.125; private const double overall_decay_base = 0.30; @@ -36,7 +35,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills protected override double StrainValueOf(DifficultyHitObject current) { var maniaCurrent = (ManiaDifficultyHitObject)current; - var endTime = maniaCurrent.BaseObject.GetEndTime(); + var endTime = maniaCurrent.EndTime; var column = maniaCurrent.BaseObject.Column; double holdFactor = 1.0; // Factor to all additional strains in case something else is held @@ -46,7 +45,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills for (int i = 0; i < holdEndTimes.Length; ++i) { // If there is at least one other overlapping end or note, then we get an addition, buuuuuut... - if (Precision.DefinitelyBigger(holdEndTimes[i], maniaCurrent.BaseObject.StartTime, 1) && Precision.DefinitelyBigger(endTime, holdEndTimes[i], 1)) + if (Precision.DefinitelyBigger(holdEndTimes[i], maniaCurrent.StartTime, 1) && Precision.DefinitelyBigger(endTime, holdEndTimes[i], 1)) holdAddition = 1.0; // ... this addition only is valid if there is _no_ other note with the same ending. Releasing multiple notes at the same time is just as easy as releasing 1 @@ -73,8 +72,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills } protected override double GetPeakStrain(double offset) - => applyDecay(individualStrain, offset - Previous[0].BaseObject.StartTime, individual_decay_base) - + applyDecay(overallStrain, offset - Previous[0].BaseObject.StartTime, overall_decay_base); + => applyDecay(individualStrain, offset - Previous[0].StartTime, individual_decay_base) + + applyDecay(overallStrain, offset - Previous[0].StartTime, overall_decay_base); private double applyDecay(double value, double deltaTime, double decayBase) => value * Math.Pow(decayBase, deltaTime / 1000); diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteOverlay.cs similarity index 61% rename from osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.cs rename to osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteOverlay.cs index 4e73883de0..6933571be8 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteOverlay.cs @@ -2,34 +2,35 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects.Drawables; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public class HoldNoteNoteSelectionBlueprint : ManiaSelectionBlueprint + public class HoldNoteNoteOverlay : CompositeDrawable { - protected new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; - + private readonly HoldNoteSelectionBlueprint holdNoteBlueprint; private readonly HoldNotePosition position; - public HoldNoteNoteSelectionBlueprint(DrawableHoldNote holdNote, HoldNotePosition position) - : base(holdNote) + public HoldNoteNoteOverlay(HoldNoteSelectionBlueprint holdNoteBlueprint, HoldNotePosition position) { + this.holdNoteBlueprint = holdNoteBlueprint; this.position = position; - InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X }; - Select(); + InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X }; } protected override void Update() { base.Update(); + var drawableObject = holdNoteBlueprint.DrawableObject; + // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly. - if (DrawableObject.IsLoaded) + if (drawableObject.IsLoaded) { - DrawableNote note = position == HoldNotePosition.Start ? (DrawableNote)DrawableObject.Head : DrawableObject.Tail; + DrawableNote note = position == HoldNotePosition.Start ? (DrawableNote)drawableObject.Head : drawableObject.Tail; Anchor = note.Anchor; Origin = note.Origin; @@ -38,8 +39,5 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints Position = note.DrawPosition; } } - - // Todo: This is temporary, since the note masks don't do anything special yet. In the future they will handle input. - public override bool HandlePositionalInput => false; } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index 1f92929392..093a8da24f 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints return; base.OnMouseUp(e); - EndPlacement(true); + EndPlacement(HitObject.Duration > 0); } private double originalStartTime; @@ -82,7 +82,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.UpdateTimeAndPosition(result); - if (PlacementActive) + if (PlacementActive == PlacementState.Active) { if (result.Time is double endTime) { diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index 1737c4d2e5..d04c5cd4aa 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -8,13 +8,14 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint + public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint { public new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; @@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints [Resolved] private OsuColour colours { get; set; } - public HoldNoteSelectionBlueprint(DrawableHoldNote hold) + public HoldNoteSelectionBlueprint(HoldNote hold) : base(hold) { } @@ -32,16 +33,11 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints private void load(IScrollingInfo scrollingInfo) { direction.BindTo(scrollingInfo.Direction); - } - - protected override void LoadComplete() - { - base.LoadComplete(); InternalChildren = new Drawable[] { - new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.Start), - new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.End), + new HoldNoteNoteOverlay(this, HoldNotePosition.Start), + new HoldNoteNoteOverlay(this, HoldNotePosition.End), new Container { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs index 5e09054667..8f25668dd0 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.UpdateTimeAndPosition(result); - if (!PlacementActive) + if (PlacementActive == PlacementState.Waiting) Column = result.Playfield as Column; } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs index 384f49d9b2..e744bd3c83 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs @@ -4,22 +4,23 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public abstract class ManiaSelectionBlueprint : OverlaySelectionBlueprint + public abstract class ManiaSelectionBlueprint : HitObjectSelectionBlueprint + where T : ManiaHitObject { public new DrawableManiaHitObject DrawableObject => (DrawableManiaHitObject)base.DrawableObject; [Resolved] private IScrollingInfo scrollingInfo { get; set; } - protected ManiaSelectionBlueprint(DrawableHitObject drawableObject) - : base(drawableObject) + protected ManiaSelectionBlueprint(T hitObject) + : base(hitObject) { RelativeSizeAxes = Axes.None; } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs index 2bff33c4cf..e2b6ee0048 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs @@ -3,13 +3,13 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; -using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Objects; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public class NoteSelectionBlueprint : ManiaSelectionBlueprint + public class NoteSelectionBlueprint : ManiaSelectionBlueprint { - public NoteSelectionBlueprint(DrawableNote note) + public NoteSelectionBlueprint(Note note) : base(note) { AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X }); diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs similarity index 78% rename from osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs rename to osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs index 445df79f6f..b0af8c503b 100644 --- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs +++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditorRuleset.cs @@ -12,16 +12,16 @@ using osu.Game.Rulesets.UI.Scrolling; namespace osu.Game.Rulesets.Mania.Edit { - public class DrawableManiaEditRuleset : DrawableManiaRuleset + public class DrawableManiaEditorRuleset : DrawableManiaRuleset { public new IScrollingInfo ScrollingInfo => base.ScrollingInfo; - public DrawableManiaEditRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) + public DrawableManiaEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) : base(ruleset, beatmap, mods) { } - protected override Playfield CreatePlayfield() => new ManiaEditPlayfield(Beatmap.Stages) + protected override Playfield CreatePlayfield() => new ManiaEditorPlayfield(Beatmap.Stages) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs index afc08dcc96..9d1f5429a1 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs @@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Mania.Edit foreach (var line in grid.Objects.OfType()) availableLines.Push(line); - grid.Clear(false); + grid.Clear(); } if (selectionTimeRange == null) diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs index 2fa3f378ff..c5a109a6d1 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs @@ -3,8 +3,8 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; -using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Mania.Edit @@ -16,20 +16,20 @@ namespace osu.Game.Rulesets.Mania.Edit { } - public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) { switch (hitObject) { - case DrawableNote note: + case Note note: return new NoteSelectionBlueprint(note); - case DrawableHoldNote holdNote: + case HoldNote holdNote: return new HoldNoteSelectionBlueprint(holdNote); } - return base.CreateBlueprintFor(hitObject); + return base.CreateHitObjectBlueprintFor(hitObject); } - protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); + protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaEditPlayfield.cs b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs similarity index 75% rename from osu.Game.Rulesets.Mania/Edit/ManiaEditPlayfield.cs rename to osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs index a42f793a77..186d50716e 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaEditPlayfield.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaEditorPlayfield.cs @@ -7,9 +7,9 @@ using System.Collections.Generic; namespace osu.Game.Rulesets.Mania.Edit { - public class ManiaEditPlayfield : ManiaPlayfield + public class ManiaEditorPlayfield : ManiaPlayfield { - public ManiaEditPlayfield(List stages) + public ManiaEditorPlayfield(List stages) : base(stages) { } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index 324670c4b2..2baec95c94 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Edit { public class ManiaHitObjectComposer : HitObjectComposer { - private DrawableManiaEditRuleset drawableRuleset; + private DrawableManiaEditorRuleset drawableRuleset; private ManiaBeatSnapGrid beatSnapGrid; private InputManager inputManager; @@ -80,7 +80,7 @@ namespace osu.Game.Rulesets.Mania.Edit protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) { - drawableRuleset = new DrawableManiaEditRuleset(ruleset, beatmap, mods); + drawableRuleset = new DrawableManiaEditorRuleset(ruleset, beatmap, mods); // This is the earliest we can cache the scrolling info to ourselves, before masks are added to the hierarchy and inject it dependencies.CacheAs(drawableRuleset.ScrollingInfo); @@ -119,5 +119,8 @@ namespace osu.Game.Rulesets.Mania.Edit beatSnapGrid.SelectionTimeRange = null; } } + + public override string ConvertSelectionToString() + => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}")); } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 50629f41a9..dc858fb54f 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -5,14 +5,14 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Mania.Edit { - public class ManiaSelectionHandler : SelectionHandler + public class ManiaSelectionHandler : EditorSelectionHandler { [Resolved] private IScrollingInfo scrollingInfo { get; set; } @@ -20,21 +20,21 @@ namespace osu.Game.Rulesets.Mania.Edit [Resolved] private HitObjectComposer composer { get; set; } - public override bool HandleMovement(MoveSelectionEvent moveEvent) + public override bool HandleMovement(MoveSelectionEvent moveEvent) { - var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint; - int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column; + var hitObjectBlueprint = (HitObjectSelectionBlueprint)moveEvent.Blueprint; + int lastColumn = ((ManiaHitObject)hitObjectBlueprint.Item).Column; performColumnMovement(lastColumn, moveEvent); return true; } - private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent) + private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent) { var maniaPlayfield = ((ManiaHitObjectComposer)composer).Playfield; - var currentColumn = maniaPlayfield.GetColumnByPosition(moveEvent.ScreenSpacePosition); + var currentColumn = maniaPlayfield.GetColumnByPosition(moveEvent.Blueprint.ScreenSpaceSelectionPoint + moveEvent.ScreenSpaceDelta); if (currentColumn == null) return; @@ -45,6 +45,7 @@ namespace osu.Game.Rulesets.Mania.Edit int minColumn = int.MaxValue; int maxColumn = int.MinValue; + // find min/max in an initial pass before actually performing the movement. foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType()) { if (obj.Column < minColumn) @@ -55,8 +56,12 @@ namespace osu.Game.Rulesets.Mania.Edit columnDelta = Math.Clamp(columnDelta, -minColumn, maniaPlayfield.TotalColumns - 1 - maxColumn); - foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType()) - obj.Column += columnDelta; + EditorBeatmap.PerformOnSelection(h => + { + maniaPlayfield.Remove(h); + ((ManiaHitObject)h).Column += columnDelta; + maniaPlayfield.Add(h); + }); } } } diff --git a/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs new file mode 100644 index 0000000000..d9a278ef29 --- /dev/null +++ b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs @@ -0,0 +1,33 @@ +// 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.Filter; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; + +namespace osu.Game.Rulesets.Mania +{ + public class ManiaFilterCriteria : IRulesetFilterCriteria + { + private FilterCriteria.OptionalRange keys; + + public bool Matches(BeatmapInfo beatmap) + { + return !keys.HasFilter || (beatmap.RulesetID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmap))); + } + + public bool TryParseCustomKeywordCriteria(string key, Operator op, string value) + { + switch (key) + { + case "key": + case "keys": + return FilterQueryParser.TryUpdateCriteriaRange(ref keys, op, value); + } + + return false; + } + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 4c729fef83..fbb9b3c466 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -9,6 +9,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; @@ -21,6 +22,7 @@ using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Filter; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Difficulty; @@ -45,7 +47,7 @@ namespace osu.Game.Rulesets.Mania public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor(); - public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime, 0.2); + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime, 0.5); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this); @@ -59,76 +61,76 @@ namespace osu.Game.Rulesets.Mania public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlag(LegacyMods.Nightcore)) + if (mods.HasFlagFast(LegacyMods.Nightcore)) yield return new ManiaModNightcore(); - else if (mods.HasFlag(LegacyMods.DoubleTime)) + else if (mods.HasFlagFast(LegacyMods.DoubleTime)) yield return new ManiaModDoubleTime(); - if (mods.HasFlag(LegacyMods.Perfect)) + if (mods.HasFlagFast(LegacyMods.Perfect)) yield return new ManiaModPerfect(); - else if (mods.HasFlag(LegacyMods.SuddenDeath)) + else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) yield return new ManiaModSuddenDeath(); - if (mods.HasFlag(LegacyMods.Cinema)) + if (mods.HasFlagFast(LegacyMods.Cinema)) yield return new ManiaModCinema(); - else if (mods.HasFlag(LegacyMods.Autoplay)) + else if (mods.HasFlagFast(LegacyMods.Autoplay)) yield return new ManiaModAutoplay(); - if (mods.HasFlag(LegacyMods.Easy)) + if (mods.HasFlagFast(LegacyMods.Easy)) yield return new ManiaModEasy(); - if (mods.HasFlag(LegacyMods.FadeIn)) + if (mods.HasFlagFast(LegacyMods.FadeIn)) yield return new ManiaModFadeIn(); - if (mods.HasFlag(LegacyMods.Flashlight)) + if (mods.HasFlagFast(LegacyMods.Flashlight)) yield return new ManiaModFlashlight(); - if (mods.HasFlag(LegacyMods.HalfTime)) + if (mods.HasFlagFast(LegacyMods.HalfTime)) yield return new ManiaModHalfTime(); - if (mods.HasFlag(LegacyMods.HardRock)) + if (mods.HasFlagFast(LegacyMods.HardRock)) yield return new ManiaModHardRock(); - if (mods.HasFlag(LegacyMods.Hidden)) + if (mods.HasFlagFast(LegacyMods.Hidden)) yield return new ManiaModHidden(); - if (mods.HasFlag(LegacyMods.Key1)) + if (mods.HasFlagFast(LegacyMods.Key1)) yield return new ManiaModKey1(); - if (mods.HasFlag(LegacyMods.Key2)) + if (mods.HasFlagFast(LegacyMods.Key2)) yield return new ManiaModKey2(); - if (mods.HasFlag(LegacyMods.Key3)) + if (mods.HasFlagFast(LegacyMods.Key3)) yield return new ManiaModKey3(); - if (mods.HasFlag(LegacyMods.Key4)) + if (mods.HasFlagFast(LegacyMods.Key4)) yield return new ManiaModKey4(); - if (mods.HasFlag(LegacyMods.Key5)) + if (mods.HasFlagFast(LegacyMods.Key5)) yield return new ManiaModKey5(); - if (mods.HasFlag(LegacyMods.Key6)) + if (mods.HasFlagFast(LegacyMods.Key6)) yield return new ManiaModKey6(); - if (mods.HasFlag(LegacyMods.Key7)) + if (mods.HasFlagFast(LegacyMods.Key7)) yield return new ManiaModKey7(); - if (mods.HasFlag(LegacyMods.Key8)) + if (mods.HasFlagFast(LegacyMods.Key8)) yield return new ManiaModKey8(); - if (mods.HasFlag(LegacyMods.Key9)) + if (mods.HasFlagFast(LegacyMods.Key9)) yield return new ManiaModKey9(); - if (mods.HasFlag(LegacyMods.KeyCoop)) + if (mods.HasFlagFast(LegacyMods.KeyCoop)) yield return new ManiaModDualStages(); - if (mods.HasFlag(LegacyMods.NoFail)) + if (mods.HasFlagFast(LegacyMods.NoFail)) yield return new ManiaModNoFail(); - if (mods.HasFlag(LegacyMods.Random)) + if (mods.HasFlagFast(LegacyMods.Random)) yield return new ManiaModRandom(); - if (mods.HasFlag(LegacyMods.Mirror)) + if (mods.HasFlagFast(LegacyMods.Mirror)) yield return new ManiaModMirror(); } @@ -237,6 +239,7 @@ namespace osu.Game.Rulesets.Mania new ManiaModDualStages(), new ManiaModMirror(), new ManiaModDifficultyAdjust(), + new ManiaModClassic(), new ManiaModInvert(), new ManiaModConstantSpeed() }; @@ -381,6 +384,11 @@ namespace osu.Game.Rulesets.Mania } } }; + + public override IRulesetFilterCriteria CreateRulesetFilterCriteria() + { + return new ManiaFilterCriteria(); + } } public enum PlayfieldType diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index de77af8306..1c89d9cd00 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -37,6 +37,11 @@ namespace osu.Game.Rulesets.Mania Current = config.GetBindable(ManiaRulesetSetting.ScrollTime), KeyboardStep = 5 }, + new SettingsCheckbox + { + LabelText = "Timing-based note colouring", + Current = config.GetBindable(ManiaRulesetSetting.TimingBasedNoteColouring), + } }; } diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs index f078345fc1..9aebf51576 100644 --- a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs +++ b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs @@ -9,12 +9,6 @@ namespace osu.Game.Rulesets.Mania { public class ManiaSkinComponent : GameplaySkinComponent { - /// - /// The intended index for this component. - /// May be null if the component does not exist in a . - /// - public readonly int? TargetColumn; - /// /// The intended for this component. /// May be null if the component is not a direct member of a . @@ -25,12 +19,10 @@ namespace osu.Game.Rulesets.Mania /// Creates a new . /// /// The component. - /// The intended index for this component. May be null if the component does not exist in a . /// The intended for this component. May be null if the component is not a direct member of a . - public ManiaSkinComponent(ManiaSkinComponents component, int? targetColumn = null, StageDefinition? stageDefinition = null) + public ManiaSkinComponent(ManiaSkinComponents component, StageDefinition? stageDefinition = null) : base(component) { - TargetColumn = targetColumn; StageDefinition = stageDefinition; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs new file mode 100644 index 0000000000..073dda9de8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModClassic.cs @@ -0,0 +1,11 @@ +// 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.Mods; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModClassic : ModClassic + { + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index cbdcd49c5b..f80c9e1f7c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -1,18 +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 osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; +using System; +using System.Linq; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModFadeIn : ManiaModHidden + public class ManiaModFadeIn : ManiaModPlayfieldCover { public override string Name => "Fade In"; public override string Acronym => "FI"; - public override IconUsage? Icon => OsuIcon.ModHidden; public override string Description => @"Keys appear out of nowhere!"; + public override double ScoreMultiplier => 1; + + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModHidden)).ToArray(); protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AlongScroll; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs index 4bdb15526f..e3ac624a6e 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs @@ -3,43 +3,17 @@ using System; using System.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModHidden : ModHidden, IApplicableToDrawableRuleset + public class ManiaModHidden : ManiaModPlayfieldCover { public override string Description => @"Keys fade out before you hit them!"; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight) }; - /// - /// The direction in which the cover should expand. - /// - protected virtual CoverExpandDirection ExpandDirection => CoverExpandDirection.AgainstScroll; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModFadeIn)).ToArray(); - public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) - { - ManiaPlayfield maniaPlayfield = (ManiaPlayfield)drawableRuleset.Playfield; - - foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns)) - { - HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; - Container hocParent = (Container)hoc.Parent; - - hocParent.Remove(hoc); - hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => - { - c.RelativeSizeAxes = Axes.Both; - c.Direction = ExpandDirection; - c.Coverage = 0.5f; - })); - } - } + protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AgainstScroll; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs index 485595cea9..12f379bddb 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs @@ -15,6 +15,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override string Name => "Mirror"; public override string Acronym => "MR"; public override ModType Type => ModType.Conversion; + public override string Description => "Notes are flipped horizontally."; public override double ScoreMultiplier => 1; public override bool Ranked => true; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs new file mode 100644 index 0000000000..3c24e91d54 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModPlayfieldCover.cs @@ -0,0 +1,52 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public abstract class ManiaModPlayfieldCover : ModHidden, IApplicableToDrawableRuleset + { + public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight) }; + + /// + /// The direction in which the cover should expand. + /// + protected abstract CoverExpandDirection ExpandDirection { get; } + + public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + ManiaPlayfield maniaPlayfield = (ManiaPlayfield)drawableRuleset.Playfield; + + foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns)) + { + HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; + Container hocParent = (Container)hoc.Parent; + + hocParent.Remove(hoc); + hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => + { + c.RelativeSizeAxes = Axes.Both; + c.Direction = ExpandDirection; + c.Coverage = 0.5f; + })); + } + } + + protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) + { + } + + protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) + { + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 4f062753a6..d1310d42eb 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -2,6 +2,7 @@ // 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.Graphics; using osu.Framework.Graphics.Containers; @@ -12,6 +13,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -29,21 +31,21 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public DrawableHoldNoteHead Head => headContainer.Child; public DrawableHoldNoteTail Tail => tailContainer.Child; - private readonly Container headContainer; - private readonly Container tailContainer; - private readonly Container tickContainer; + private Container headContainer; + private Container tailContainer; + private Container tickContainer; /// /// Contains the size of the hold note covering the whole head/tail bounds. The size of this container changes as the hold note is being pressed. /// - private readonly Container sizingContainer; + private Container sizingContainer; /// /// Contains the contents of the hold note that should be masked as the hold note is being pressed. Follows changes in the size of . /// - private readonly Container maskingContainer; + private Container maskingContainer; - private readonly SkinnableDrawable bodyPiece; + private SkinnableDrawable bodyPiece; /// /// Time at which the user started holding this hold note. Null if the user is not holding this hold note. @@ -60,11 +62,19 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// private double? releaseTime; + public DrawableHoldNote() + : this(null) + { + } + public DrawableHoldNote(HoldNote hitObject) : base(hitObject) { - RelativeSizeAxes = Axes.X; + } + [BackgroundDependencyLoader] + private void load() + { Container maskedContents; AddRangeInternal(new Drawable[] @@ -86,7 +96,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables headContainer = new Container { RelativeSizeAxes = Axes.Both } } }, - bodyPiece = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HoldNoteBody, hitObject.Column), _ => new DefaultBodyPiece + bodyPiece = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece { RelativeSizeAxes = Axes.Both, }) @@ -105,6 +115,16 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }); } + protected override void OnApply() + { + base.OnApply(); + + sizingContainer.Size = Vector2.One; + HoldStartTime = null; + HoldBrokenTime = null; + releaseTime = null; + } + protected override void AddNestedHitObject(DrawableHitObject hitObject) { base.AddNestedHitObject(hitObject); @@ -128,37 +148,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected override void ClearNestedHitObjects() { base.ClearNestedHitObjects(); - headContainer.Clear(); - tailContainer.Clear(); - tickContainer.Clear(); + headContainer.Clear(false); + tailContainer.Clear(false); + tickContainer.Clear(false); } protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) { switch (hitObject) { - case TailNote _: - return new DrawableHoldNoteTail(this) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AccentColour = { BindTarget = AccentColour } - }; + case TailNote tail: + return new DrawableHoldNoteTail(tail); - case Note _: - return new DrawableHoldNoteHead(this) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AccentColour = { BindTarget = AccentColour } - }; + case HeadNote head: + return new DrawableHoldNoteHead(head); case HoldNoteTick tick: - return new DrawableHoldNoteTick(tick) - { - HoldStartTime = () => HoldStartTime, - AccentColour = { BindTarget = AccentColour } - }; + return new DrawableHoldNoteTick(tick); } return base.CreateNestedHitObject(hitObject); @@ -221,7 +227,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables // As the note is being held, adjust the size of the sizing container. This has two effects: // 1. The contained masking container will mask the body and ticks. // 2. The head note will move along with the new "head position" in the container. - if (Head.IsHit && releaseTime == null) + if (Head.IsHit && releaseTime == null && DrawHeight > 0) { // How far past the hit target this hold note is. Always a positive value. float yOffset = Math.Max(0, Direction.Value == ScrollingDirection.Up ? -Y : Y); @@ -233,6 +239,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { if (Tail.AllJudged) { + foreach (var tick in tickContainer) + { + if (!tick.Judged) + tick.MissForcefully(); + } + ApplyResult(r => r.Type = r.Judgement.MaxResult); endHold(); } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs index 75dcf0e55e..be600f0d47 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs @@ -1,6 +1,9 @@ // 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.Graphics; +using osu.Game.Rulesets.Objects.Drawables; + namespace osu.Game.Rulesets.Mania.Objects.Drawables { /// @@ -10,11 +13,18 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteHead; - public DrawableHoldNoteHead(DrawableHoldNote holdNote) - : base(holdNote.HitObject.Head) + public DrawableHoldNoteHead() + : this(null) { } + public DrawableHoldNoteHead(HeadNote headNote) + : base(headNote) + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + } + public void UpdateResult() => base.UpdateResult(true); protected override void UpdateInitialTransforms() @@ -25,6 +35,14 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables LifetimeEnd = LifetimeStart + 30000; } + protected override void UpdateHitStateTransforms(ArmedState state) + { + // suppress the base call explicitly. + // the hold note head should never change its visual state on its own due to the "freezing" mechanic + // (when hit, it remains visible in place at the judgement line; when dropped, it will scroll past the line). + // it will be hidden along with its parenting hold note when required. + } + public override bool OnPressed(ManiaAction action) => false; // Handled by the hold note public override void OnReleased(ManiaAction action) diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs index 3a00933e4d..18aa3f66d4 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; +using osu.Framework.Graphics; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Objects.Drawables @@ -20,12 +21,18 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail; - private readonly DrawableHoldNote holdNote; + protected DrawableHoldNote HoldNote => (DrawableHoldNote)ParentHitObject; - public DrawableHoldNoteTail(DrawableHoldNote holdNote) - : base(holdNote.HitObject.Tail) + public DrawableHoldNoteTail() + : this(null) { - this.holdNote = holdNote; + } + + public DrawableHoldNoteTail(TailNote tailNote) + : base(tailNote) + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; } public void UpdateResult() => base.UpdateResult(true); @@ -54,7 +61,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables ApplyResult(r => { // If the head wasn't hit or the hold note was broken, cap the max score to Meh. - if (result > HitResult.Meh && (!holdNote.Head.IsHit || holdNote.HoldBrokenTime != null)) + if (result > HitResult.Meh && (!HoldNote.Head.IsHit || HoldNote.HoldBrokenTime != null)) result = HitResult.Meh; r.Type = result; diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs index 98931dceed..f040dad135 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs @@ -2,7 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; +using System.Diagnostics; +using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -19,38 +20,48 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// /// References the time at which the user started holding the hold note. /// - public Func HoldStartTime; + private Func holdStartTime; + + private Container glowContainer; + + public DrawableHoldNoteTick() + : this(null) + { + } public DrawableHoldNoteTick(HoldNoteTick hitObject) : base(hitObject) { - Container glowContainer; - Anchor = Anchor.TopCentre; Origin = Anchor.TopCentre; RelativeSizeAxes = Axes.X; - Size = new Vector2(1); + } - AddRangeInternal(new[] + [BackgroundDependencyLoader] + private void load() + { + AddInternal(glowContainer = new CircularContainer { - glowContainer = new CircularContainer + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new[] + new Box { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true } } }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); AccentColour.BindValueChanged(colour => { @@ -64,12 +75,29 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }, true); } + protected override void OnApply() + { + base.OnApply(); + + Debug.Assert(ParentHitObject != null); + + var holdNote = (DrawableHoldNote)ParentHitObject; + holdStartTime = () => holdNote.HoldStartTime; + } + + protected override void OnFree() + { + base.OnFree(); + + holdStartTime = null; + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (Time.Current < HitObject.StartTime) return; - var startTime = HoldStartTime?.Invoke(); + var startTime = holdStartTime?.Invoke(); if (startTime == null || startTime > HitObject.StartTime) ApplyResult(r => r.Type = r.Judgement.MinResult); diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index 1550faee50..380ab35339 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -6,6 +6,7 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Audio; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.Mania.UI; @@ -24,6 +25,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables [Resolved(canBeNull: true)] private ManiaPlayfield playfield { get; set; } + /// + /// Gets the samples that are played by this object during gameplay. + /// + public ISampleInfo[] GetGameplaySamples() => Samples.Samples; + protected override float SamplePlaybackPosition { get @@ -44,6 +50,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected DrawableManiaHitObject(ManiaHitObject hitObject) : base(hitObject) { + RelativeSizeAxes = Axes.X; } [BackgroundDependencyLoader(true)] @@ -53,9 +60,31 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Action.BindTo(action); Direction.BindTo(scrollingInfo.Direction); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Direction.BindValueChanged(OnDirectionChanged, true); } + protected override void OnApply() + { + base.OnApply(); + + if (ParentHitObject != null) + AccentColour.BindTo(ParentHitObject.AccentColour); + } + + protected override void OnFree() + { + base.OnFree(); + + if (ParentHitObject != null) + AccentColour.UnbindFrom(ParentHitObject.AccentColour); + } + private double computedLifetimeStart; public override double LifetimeStart @@ -141,12 +170,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public abstract class DrawableManiaHitObject : DrawableManiaHitObject where TObject : ManiaHitObject { - public new readonly TObject HitObject; + public new TObject HitObject => (TObject)base.HitObject; protected DrawableManiaHitObject(TObject hitObject) : base(hitObject) { - HitObject = hitObject; } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs index b512986ccb..33d872dfb6 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs @@ -2,13 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; using osu.Game.Skinning; +using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -17,23 +23,49 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// public class DrawableNote : DrawableManiaHitObject, IKeyBindingHandler { + [Resolved] + private OsuColour colours { get; set; } + + [Resolved(canBeNull: true)] + private IBeatmap beatmap { get; set; } + + private readonly Bindable configTimingBasedNoteColouring = new Bindable(); + protected virtual ManiaSkinComponents Component => ManiaSkinComponents.Note; - private readonly Drawable headPiece; + private Drawable headPiece; + + public DrawableNote() + : this(null) + { + } public DrawableNote(Note hitObject) : base(hitObject) { - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + } - AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component, hitObject.Column), _ => new DefaultNotePiece()) + [BackgroundDependencyLoader(true)] + private void load(ManiaRulesetConfigManager rulesetConfig) + { + rulesetConfig?.BindWith(ManiaRulesetSetting.TimingBasedNoteColouring, configTimingBasedNoteColouring); + + AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component), _ => new DefaultNotePiece()) { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + configTimingBasedNoteColouring.BindValueChanged(_ => updateSnapColour()); + StartTimeBindable.BindValueChanged(_ => updateSnapColour(), true); + } + protected override void OnDirectionChanged(ValueChangedEvent e) { base.OnDirectionChanged(e); @@ -73,5 +105,14 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables public virtual void OnReleased(ManiaAction action) { } + + private void updateSnapColour() + { + if (beatmap == null || HitObject == null) return; + + int snapDivisor = beatmap.ControlPointInfo.GetClosestBeatDivisor(HitObject.StartTime); + + Colour = configTimingBasedNoteColouring.Value ? BindableBeatDivisor.GetColourFor(snapDivisor, colours) : Color4.White; + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/HeadNote.cs b/osu.Game.Rulesets.Mania/Objects/HeadNote.cs new file mode 100644 index 0000000000..e69cc62aed --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/HeadNote.cs @@ -0,0 +1,9 @@ +// 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.Rulesets.Mania.Objects +{ + public class HeadNote : Note + { + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs index 6cc7ff92d3..43e876b7aa 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs @@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Mania.Objects /// /// The head note of the hold. /// - public Note Head { get; private set; } + public HeadNote Head { get; private set; } /// /// The tail note of the hold. @@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Mania.Objects createTicks(cancellationToken); - AddNested(Head = new Note + AddNested(Head = new HeadNote { StartTime = StartTime, Column = Column, diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs index 27bf50493d..6289744df1 100644 --- a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Game.Rulesets.Mania.Objects.Types; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs index 3ebbe5af8e..517b708691 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs @@ -3,14 +3,14 @@ using System.Collections.Generic; using System.Linq; -using osu.Game.Replays; using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Replays; namespace osu.Game.Rulesets.Mania.Replays { - internal class ManiaAutoGenerator : AutoGenerator + internal class ManiaAutoGenerator : AutoGenerator { public const double RELEASE_DELAY = 20; @@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Mania.Replays public ManiaAutoGenerator(ManiaBeatmap beatmap) : base(beatmap) { - Replay = new Replay(); - columnActions = new ManiaAction[Beatmap.TotalColumns]; var normalAction = ManiaAction.Key1; @@ -42,12 +40,10 @@ namespace osu.Game.Rulesets.Mania.Replays } } - protected Replay Replay; - - public override Replay Generate() + protected override void GenerateFrames() { if (Beatmap.HitObjects.Count == 0) - return Replay; + return; var pointGroups = generateActionPoints().GroupBy(a => a.Time).OrderBy(g => g.First().Time); @@ -69,14 +65,8 @@ namespace osu.Game.Rulesets.Mania.Replays } } - // todo: can be removed once FramedReplayInputHandler correctly handles rewinding before first frame. - if (Replay.Frames.Count == 0) - Replay.Frames.Add(new ManiaReplayFrame(group.First().Time - 1)); - - Replay.Frames.Add(new ManiaReplayFrame(group.First().Time, actions.ToArray())); + Frames.Add(new ManiaReplayFrame(group.First().Time, actions.ToArray())); } - - return Replay; } private IEnumerable generateActionPoints() @@ -85,20 +75,28 @@ namespace osu.Game.Rulesets.Mania.Replays { var currentObject = Beatmap.HitObjects[i]; var nextObjectInColumn = GetNextObject(i); // Get the next object that requires pressing the same button - - double endTime = currentObject.GetEndTime(); - - bool canDelayKeyUp = nextObjectInColumn == null || - nextObjectInColumn.StartTime > endTime + RELEASE_DELAY; - - double calculatedDelay = canDelayKeyUp ? RELEASE_DELAY : (nextObjectInColumn.StartTime - endTime) * 0.9; + var releaseTime = calculateReleaseTime(currentObject, nextObjectInColumn); yield return new HitPoint { Time = currentObject.StartTime, Column = currentObject.Column }; - yield return new ReleasePoint { Time = endTime + calculatedDelay, Column = currentObject.Column }; + yield return new ReleasePoint { Time = releaseTime, Column = currentObject.Column }; } } + private double calculateReleaseTime(HitObject currentObject, HitObject nextObject) + { + double endTime = currentObject.GetEndTime(); + + if (currentObject is HoldNote) + // hold note releases must be timed exactly. + return endTime; + + bool canDelayKeyUpFully = nextObject == null || + nextObject.StartTime > endTime + RELEASE_DELAY; + + return endTime + (canDelayKeyUpFully ? RELEASE_DELAY : (nextObject.StartTime - endTime) * 0.9); + } + protected override HitObject GetNextObject(int currentIndex) { int desiredColumn = Beatmap.HitObjects[currentIndex].Column; diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index 71cc0bdf1f..48b377c794 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -7,8 +7,8 @@ namespace osu.Game.Rulesets.Mania.Scoring { internal class ManiaScoreProcessor : ScoreProcessor { - protected override double DefaultAccuracyPortion => 0.95; + protected override double DefaultAccuracyPortion => 0.99; - protected override double DefaultComboPortion => 0.05; + protected override double DefaultComboPortion => 0.01; } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs index 73aece1ed4..e4d466dca5 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyHitExplosion.cs @@ -17,6 +17,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { public class LegacyHitExplosion : LegacyManiaColumnElement, IHitExplosion { + public const double FADE_IN_DURATION = 80; + private readonly IBindable direction = new Bindable(); private Drawable explosion; @@ -72,7 +74,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy (explosion as IFramedAnimation)?.GotoFrame(0); - explosion?.FadeInFromZero(80) + explosion?.FadeInFromZero(FADE_IN_DURATION) .Then().FadeOut(120); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs index 78ccb83a8c..10319a7d4d 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/LegacyKeyArea.cs @@ -101,8 +101,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { if (action == column.Action.Value) { - upSprite.FadeTo(1); - downSprite.FadeTo(0); + upSprite.Delay(LegacyHitExplosion.FADE_IN_DURATION).FadeTo(1); + downSprite.Delay(LegacyHitExplosion.FADE_IN_DURATION).FadeTo(0); } } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index cbbbacfe19..261b8b1fad 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -125,7 +125,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy break; } - return null; + return Source.GetDrawableComponent(component); } private Drawable getResult(HitResult result) @@ -140,7 +140,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return animation == null ? null : new LegacyManiaJudgementPiece(result, animation); } - public override Sample GetSample(ISampleInfo sampleInfo) + public override ISample GetSample(ISampleInfo sampleInfo) { // layered hit sounds never play in mania if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered) diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index d2a9b69b60..9b5893b268 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -17,6 +17,7 @@ using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Skinning; using osuTK; using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; namespace osu.Game.Rulesets.Mania.UI @@ -27,6 +28,12 @@ namespace osu.Game.Rulesets.Mania.UI public const float COLUMN_WIDTH = 80; public const float SPECIAL_COLUMN_WIDTH = 70; + /// + /// For hitsounds played by this (i.e. not as a result of hitting a hitobject), + /// a certain number of samples are allowed to be played concurrently so that it feels better when spam-pressing the key. + /// + private const int max_concurrent_hitsounds = OsuGameBase.SAMPLE_CONCURRENCY; + /// /// The index of this column as part of the whole playfield. /// @@ -38,6 +45,7 @@ namespace osu.Game.Rulesets.Mania.UI internal readonly Container TopLevelContainer; private readonly DrawablePool hitExplosionPool; private readonly OrderedHitPolicy hitPolicy; + private readonly Container hitSounds; public Container UnderlayElements => HitObjectArea.UnderlayElements; @@ -48,7 +56,7 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Y; Width = COLUMN_WIDTH; - Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, Index), _ => new DefaultColumnBackground()) + Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) { RelativeSizeAxes = Axes.Both }; @@ -59,17 +67,36 @@ namespace osu.Game.Rulesets.Mania.UI // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements background.CreateProxy(), HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both }, - new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, Index), _ => new DefaultKeyArea()) + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) { RelativeSizeAxes = Axes.Both }, background, + hitSounds = new Container + { + Name = "Column samples pool", + RelativeSizeAxes = Axes.Both, + Children = Enumerable.Range(0, max_concurrent_hitsounds).Select(_ => new SkinnableSound()).ToArray() + }, TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both } }; hitPolicy = new OrderedHitPolicy(HitObjectContainer); TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); + + RegisterPool(10, 50); + RegisterPool(10, 50); + RegisterPool(10, 50); + RegisterPool(10, 50); + RegisterPool(50, 250); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + NewResult += OnNewResult; } public ColumnType ColumnType { get; set; } @@ -85,28 +112,14 @@ namespace osu.Game.Rulesets.Mania.UI return dependencies; } - /// - /// Adds a DrawableHitObject to this Playfield. - /// - /// The DrawableHitObject to add. - public override void Add(DrawableHitObject hitObject) + protected override void OnNewDrawableHitObject(DrawableHitObject drawableHitObject) { - hitObject.AccentColour.Value = AccentColour; - hitObject.OnNewResult += OnNewResult; + base.OnNewDrawableHitObject(drawableHitObject); - DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)hitObject; + DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)drawableHitObject; + + maniaObject.AccentColour.Value = AccentColour; maniaObject.CheckHittable = hitPolicy.IsHittable; - - base.Add(hitObject); - } - - public override bool Remove(DrawableHitObject h) - { - if (!base.Remove(h)) - return false; - - h.OnNewResult -= OnNewResult; - return true; } internal void OnNewResult(DrawableHitObject judgedObject, JudgementResult result) @@ -120,6 +133,8 @@ namespace osu.Game.Rulesets.Mania.UI HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result))); } + private int nextHitSoundIndex; + public bool OnPressed(ManiaAction action) { if (action != Action.Value) @@ -131,7 +146,15 @@ namespace osu.Game.Rulesets.Mania.UI HitObjectContainer.Objects.FirstOrDefault(h => h.HitObject.StartTime > Time.Current) ?? HitObjectContainer.Objects.LastOrDefault(); - nextObject?.PlaySamples(); + if (nextObject is DrawableManiaHitObject maniaObject) + { + var hitSound = hitSounds[nextHitSoundIndex]; + + hitSound.Samples = maniaObject.GetGameplaySamples(); + hitSound.Play(); + + nextHitSoundIndex = (nextHitSoundIndex + 1) % max_concurrent_hitsounds; + } return true; } diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs index b365ae45a9..f69d2aafdc 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mania.UI.Components RelativeSizeAxes = Axes.Both, Depth = 2, }, - hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget, columnIndex), _ => new DefaultHitTarget()) + hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget), _ => new DefaultHitTarget()) { RelativeSizeAxes = Axes.X, Depth = 1 diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index 4ee060e91e..e497646a13 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -18,7 +18,6 @@ using osu.Game.Replays; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -134,20 +133,7 @@ namespace osu.Game.Rulesets.Mania.UI protected override PassThroughInputManager CreateInputManager() => new ManiaInputManager(Ruleset.RulesetInfo, Variant); - public override DrawableHitObject CreateDrawableRepresentation(ManiaHitObject h) - { - switch (h) - { - case HoldNote holdNote: - return new DrawableHoldNote(holdNote); - - case Note note: - return new DrawableNote(note); - - default: - return null; - } - } + public override DrawableHitObject CreateDrawableRepresentation(ManiaHitObject h) => null; protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new ManiaFramedReplayInputHandler(replay); diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs index 271e432e8d..8830c440c0 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs @@ -9,6 +9,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osuTK; @@ -56,6 +57,10 @@ namespace osu.Game.Rulesets.Mania.UI } } + public override void Add(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Add(hitObject); + + public override bool Remove(HitObject hitObject) => getStageByColumn(((ManiaHitObject)hitObject).Column).Remove(hitObject); + public override void Add(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Add(h); public override bool Remove(DrawableHitObject h) => getStageByColumn(((ManiaHitObject)h.HitObject).Column).Remove(h); diff --git a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs index 64b7d7d550..90d3c6c4c7 100644 --- a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mania.UI [BackgroundDependencyLoader] private void load() { - InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion, column.Index), _ => new DefaultHitExplosion()) + InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion), _ => new DefaultHitExplosion()) { RelativeSizeAxes = Axes.Both }; diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index dc34bffab1..8c703e7a8a 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -11,6 +11,7 @@ using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; @@ -132,33 +133,19 @@ namespace osu.Game.Rulesets.Mania.UI } } - public override void Add(DrawableHitObject h) + protected override void LoadComplete() { - var maniaObject = (ManiaHitObject)h.HitObject; - - int columnIndex = -1; - - maniaObject.ColumnBindable.BindValueChanged(_ => - { - if (columnIndex != -1) - Columns.ElementAt(columnIndex).Remove(h); - - columnIndex = maniaObject.Column - firstColumnIndex; - Columns.ElementAt(columnIndex).Add(h); - }, true); - - h.OnNewResult += OnNewResult; + base.LoadComplete(); + NewResult += OnNewResult; } - public override bool Remove(DrawableHitObject h) - { - var maniaObject = (ManiaHitObject)h.HitObject; - int columnIndex = maniaObject.Column - firstColumnIndex; - Columns.ElementAt(columnIndex).Remove(h); + public override void Add(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Add(hitObject); - h.OnNewResult -= OnNewResult; - return true; - } + public override bool Remove(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Remove(hitObject); + + public override void Add(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Add(h); + + public override bool Remove(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Remove(h); public void Add(BarLine barline) => base.Add(new DrawableBarLine(barline)); diff --git a/osu.Game.Rulesets.Osu.Tests.Android/osu.Game.Rulesets.Osu.Tests.Android.csproj b/osu.Game.Rulesets.Osu.Tests.Android/osu.Game.Rulesets.Osu.Tests.Android.csproj index dcf1573522..f4b673f10b 100644 --- a/osu.Game.Rulesets.Osu.Tests.Android/osu.Game.Rulesets.Osu.Tests.Android.csproj +++ b/osu.Game.Rulesets.Osu.Tests.Android/osu.Game.Rulesets.Osu.Tests.Android.csproj @@ -14,6 +14,11 @@ Properties\AndroidManifest.xml armeabi-v7a;x86;arm64-v8a + + None + cjk;mideast;other;rare;west + true + @@ -35,5 +40,10 @@ osu.Game + + + 5.0.0 + + \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs new file mode 100644 index 0000000000..a6873c6de9 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckOffscreenObjectsTest.cs @@ -0,0 +1,250 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Checks; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks +{ + [TestFixture] + public class CheckOffscreenObjectsTest + { + private static readonly Vector2 playfield_centre = OsuPlayfield.BASE_SIZE * 0.5f; + + private CheckOffscreenObjects check; + + [SetUp] + public void Setup() + { + check = new CheckOffscreenObjects(); + } + + [Test] + public void TestCircleInCenter() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 3000, + Position = playfield_centre + } + } + }); + } + + [Test] + public void TestCircleNearEdge() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 3000, + Position = new Vector2(5, 5) + } + } + }); + } + + [Test] + public void TestCircleNearEdgeStackedOffscreen() + { + assertOffscreenCircle(new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 3000, + Position = new Vector2(5, 5), + StackHeight = 5 + } + } + }); + } + + [Test] + public void TestCircleOffscreen() + { + assertOffscreenCircle(new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 3000, + Position = new Vector2(0, 0) + } + } + }); + } + + [Test] + public void TestSliderInCenter() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new Slider + { + StartTime = 3000, + Position = new Vector2(420, 240), + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0), PathType.Linear), + new PathControlPoint(new Vector2(-100, 0)) + }), + } + } + }); + } + + [Test] + public void TestSliderNearEdge() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new Slider + { + StartTime = 3000, + Position = playfield_centre, + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0), PathType.Linear), + new PathControlPoint(new Vector2(0, -playfield_centre.Y + 5)) + }), + } + } + }); + } + + [Test] + public void TestSliderNearEdgeStackedOffscreen() + { + assertOffscreenSlider(new Beatmap + { + HitObjects = new List + { + new Slider + { + StartTime = 3000, + Position = playfield_centre, + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0), PathType.Linear), + new PathControlPoint(new Vector2(0, -playfield_centre.Y + 5)) + }), + StackHeight = 5 + } + } + }); + } + + [Test] + public void TestSliderOffscreenStart() + { + assertOffscreenSlider(new Beatmap + { + HitObjects = new List + { + new Slider + { + StartTime = 3000, + Position = new Vector2(0, 0), + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0), PathType.Linear), + new PathControlPoint(playfield_centre) + }), + } + } + }); + } + + [Test] + public void TestSliderOffscreenEnd() + { + assertOffscreenSlider(new Beatmap + { + HitObjects = new List + { + new Slider + { + StartTime = 3000, + Position = playfield_centre, + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0), PathType.Linear), + new PathControlPoint(-playfield_centre) + }), + } + } + }); + } + + [Test] + public void TestSliderOffscreenPath() + { + assertOffscreenSlider(new Beatmap + { + HitObjects = new List + { + new Slider + { + StartTime = 3000, + Position = playfield_centre, + Path = new SliderPath(new[] + { + // Circular arc shoots over the top of the screen. + new PathControlPoint(new Vector2(0, 0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(-100, -200)), + new PathControlPoint(new Vector2(100, -200)) + }), + } + } + }); + } + + private void assertOk(IBeatmap beatmap) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + Assert.That(check.Run(context), Is.Empty); + } + + private void assertOffscreenCircle(IBeatmap beatmap) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckOffscreenObjects.IssueTemplateOffscreenCircle); + } + + private void assertOffscreenSlider(IBeatmap beatmap) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckOffscreenObjects.IssueTemplateOffscreenSlider); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs index 66cd405195..315493318d 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); Add(drawableObject = new DrawableHitCircle(hitCircle)); - AddBlueprint(blueprint = new TestBlueprint(drawableObject)); + AddBlueprint(blueprint = new TestBlueprint(hitCircle), drawableObject); }); [Test] @@ -63,8 +63,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { public new HitCirclePiece CirclePiece => base.CirclePiece; - public TestBlueprint(DrawableHitCircle drawableCircle) - : base(drawableCircle) + public TestBlueprint(HitCircle circle) + : base(circle) { } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorSelectInvalidPath.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorSelectInvalidPath.cs new file mode 100644 index 0000000000..d0348c1b6b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorSelectInvalidPath.cs @@ -0,0 +1,46 @@ +// 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.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneOsuEditorSelectInvalidPath : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [Test] + public void TestSelectDoesNotModify() + { + Slider slider = new Slider { StartTime = 0, Position = new Vector2(320, 40) }; + + PathControlPoint[] points = + { + new PathControlPoint(new Vector2(0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(-100, 0)), + new PathControlPoint(new Vector2(100, 20)) + }; + + int preSelectVersion = -1; + AddStep("add slider", () => + { + slider.Path = new SliderPath(points); + EditorBeatmap.Add(slider); + preSelectVersion = slider.Path.Version.Value; + }); + + AddStep("select added slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + + AddAssert("slider same path", () => slider.Path.Version.Value == preSelectVersion); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs index 738a21b17e..35b79aa8ac 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs @@ -4,9 +4,11 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Tests.Visual; @@ -14,7 +16,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Editor { - public class TestScenePathControlPointVisualiser : OsuTestScene + public class TestScenePathControlPointVisualiser : OsuManualInputManagerTestScene { private Slider slider; private PathControlPointVisualiser visualiser; @@ -43,12 +45,145 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor }); } + [Test] + public void TestPerfectCurveTooManyPoints() + { + createVisualiser(true); + + addControlPointStep(new Vector2(200), PathType.Bezier); + addControlPointStep(new Vector2(300)); + addControlPointStep(new Vector2(500, 300)); + addControlPointStep(new Vector2(700, 200)); + addControlPointStep(new Vector2(500, 100)); + + // Must be both hovering and selecting the control point for the context menu to work. + moveMouseToControlPoint(1); + AddStep("select control point", () => visualiser.Pieces[1].IsSelected.Value = true); + addContextMenuItemStep("Perfect curve"); + + assertControlPointPathType(0, PathType.Bezier); + assertControlPointPathType(1, PathType.PerfectCurve); + assertControlPointPathType(3, PathType.Bezier); + } + + [Test] + public void TestPerfectCurveLastThreePoints() + { + createVisualiser(true); + + addControlPointStep(new Vector2(200), PathType.Bezier); + addControlPointStep(new Vector2(300)); + addControlPointStep(new Vector2(500, 300)); + addControlPointStep(new Vector2(700, 200)); + addControlPointStep(new Vector2(500, 100)); + + moveMouseToControlPoint(2); + AddStep("select control point", () => visualiser.Pieces[2].IsSelected.Value = true); + addContextMenuItemStep("Perfect curve"); + + assertControlPointPathType(0, PathType.Bezier); + assertControlPointPathType(2, PathType.PerfectCurve); + assertControlPointPathType(4, null); + } + + [Test] + public void TestPerfectCurveLastTwoPoints() + { + createVisualiser(true); + + addControlPointStep(new Vector2(200), PathType.Bezier); + addControlPointStep(new Vector2(300)); + addControlPointStep(new Vector2(500, 300)); + addControlPointStep(new Vector2(700, 200)); + addControlPointStep(new Vector2(500, 100)); + + moveMouseToControlPoint(3); + AddStep("select control point", () => visualiser.Pieces[3].IsSelected.Value = true); + addContextMenuItemStep("Perfect curve"); + + assertControlPointPathType(0, PathType.Bezier); + AddAssert("point 3 is not inherited", () => slider.Path.ControlPoints[3].Type != null); + } + + [Test] + public void TestPerfectCurveTooManyPointsLinear() + { + createVisualiser(true); + + addControlPointStep(new Vector2(200), PathType.Linear); + addControlPointStep(new Vector2(300)); + addControlPointStep(new Vector2(500, 300)); + addControlPointStep(new Vector2(700, 200)); + addControlPointStep(new Vector2(500, 100)); + + // Must be both hovering and selecting the control point for the context menu to work. + moveMouseToControlPoint(1); + AddStep("select control point", () => visualiser.Pieces[1].IsSelected.Value = true); + addContextMenuItemStep("Perfect curve"); + + assertControlPointPathType(0, PathType.Linear); + assertControlPointPathType(1, PathType.PerfectCurve); + assertControlPointPathType(3, PathType.Linear); + } + + [Test] + public void TestPerfectCurveChangeToBezier() + { + createVisualiser(true); + + addControlPointStep(new Vector2(200), PathType.Bezier); + addControlPointStep(new Vector2(300), PathType.PerfectCurve); + addControlPointStep(new Vector2(500, 300)); + addControlPointStep(new Vector2(700, 200), PathType.Bezier); + addControlPointStep(new Vector2(500, 100)); + + moveMouseToControlPoint(3); + AddStep("select control point", () => visualiser.Pieces[3].IsSelected.Value = true); + addContextMenuItemStep("Inherit"); + + assertControlPointPathType(0, PathType.Bezier); + assertControlPointPathType(1, PathType.Bezier); + assertControlPointPathType(3, null); + } + private void createVisualiser(bool allowSelection) => AddStep("create visualiser", () => Child = visualiser = new PathControlPointVisualiser(slider, allowSelection) { Anchor = Anchor.Centre, Origin = Anchor.Centre }); - private void addControlPointStep(Vector2 position) => AddStep($"add control point {position}", () => slider.Path.ControlPoints.Add(new PathControlPoint(position))); + private void addControlPointStep(Vector2 position) => addControlPointStep(position, null); + + private void addControlPointStep(Vector2 position, PathType? type) + { + AddStep($"add {type} control point at {position}", () => + { + slider.Path.ControlPoints.Add(new PathControlPoint(position, type)); + }); + } + + private void moveMouseToControlPoint(int index) + { + AddStep($"move mouse to control point {index}", () => + { + Vector2 position = slider.Path.ControlPoints[index].Position.Value; + InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position)); + }); + } + + private void assertControlPointPathType(int controlPointIndex, PathType? type) + { + AddAssert($"point {controlPointIndex} is {type}", () => slider.Path.ControlPoints[controlPointIndex].Type.Value == type); + } + + private void addContextMenuItemStep(string contextMenuText) + { + AddStep($"click context menu item \"{contextMenuText}\"", () => + { + MenuItem item = visualiser.ContextMenuItems[1].Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); + + item?.Action?.Value(); + }); + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs new file mode 100644 index 0000000000..24b947c854 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs @@ -0,0 +1,175 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneSliderControlPointPiece : SelectionBlueprintTestScene + { + private Slider slider; + private DrawableSlider drawableObject; + + [SetUp] + public void Setup() => Schedule(() => + { + Clear(); + + slider = new Slider + { + Position = new Vector2(256, 192), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(150, 150)), + new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(400, 0)), + new PathControlPoint(new Vector2(400, 150)) + }) + }; + + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); + + Add(drawableObject = new DrawableSlider(slider)); + AddBlueprint(new TestSliderBlueprint(slider), drawableObject); + }); + + [Test] + public void TestDragControlPoint() + { + moveMouseToControlPoint(1); + AddStep("hold", () => InputManager.PressButton(MouseButton.Left)); + + addMovementStep(new Vector2(150, 50)); + AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); + + assertControlPointPosition(1, new Vector2(150, 50)); + assertControlPointType(0, PathType.PerfectCurve); + } + + [Test] + public void TestDragControlPointAlmostLinearlyExterior() + { + moveMouseToControlPoint(1); + AddStep("hold", () => InputManager.PressButton(MouseButton.Left)); + + addMovementStep(new Vector2(400, 0.01f)); + AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); + + assertControlPointPosition(1, new Vector2(400, 0.01f)); + assertControlPointType(0, PathType.Bezier); + } + + [Test] + public void TestDragControlPointPathRecovery() + { + moveMouseToControlPoint(1); + AddStep("hold", () => InputManager.PressButton(MouseButton.Left)); + + addMovementStep(new Vector2(400, 0.01f)); + assertControlPointType(0, PathType.Bezier); + + addMovementStep(new Vector2(150, 50)); + AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); + + assertControlPointPosition(1, new Vector2(150, 50)); + assertControlPointType(0, PathType.PerfectCurve); + } + + [Test] + public void TestDragControlPointPathRecoveryOtherSegment() + { + moveMouseToControlPoint(4); + AddStep("hold", () => InputManager.PressButton(MouseButton.Left)); + + addMovementStep(new Vector2(350, 0.01f)); + assertControlPointType(2, PathType.Bezier); + + addMovementStep(new Vector2(150, 150)); + AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); + + assertControlPointPosition(4, new Vector2(150, 150)); + assertControlPointType(2, PathType.PerfectCurve); + } + + [Test] + public void TestDragControlPointPathAfterChangingType() + { + AddStep("change type to bezier", () => slider.Path.ControlPoints[2].Type.Value = PathType.Bezier); + AddStep("add point", () => slider.Path.ControlPoints.Add(new PathControlPoint(new Vector2(500, 10)))); + AddStep("change type to perfect", () => slider.Path.ControlPoints[3].Type.Value = PathType.PerfectCurve); + + moveMouseToControlPoint(4); + AddStep("hold", () => InputManager.PressButton(MouseButton.Left)); + + assertControlPointType(3, PathType.PerfectCurve); + + addMovementStep(new Vector2(350, 0.01f)); + AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); + + assertControlPointPosition(4, new Vector2(350, 0.01f)); + assertControlPointType(3, PathType.Bezier); + } + + private void addMovementStep(Vector2 relativePosition) + { + AddStep($"move mouse to {relativePosition}", () => + { + Vector2 position = slider.Position + relativePosition; + InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); + }); + } + + private void moveMouseToControlPoint(int index) + { + AddStep($"move mouse to control point {index}", () => + { + Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position.Value; + InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); + }); + } + + private void assertControlPointType(int index, PathType type) => AddAssert($"control point {index} is {type}", () => slider.Path.ControlPoints[index].Type.Value == type); + + private void assertControlPointPosition(int index, Vector2 position) => + AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, slider.Path.ControlPoints[index].Position.Value, 1)); + + private class TestSliderBlueprint : SliderSelectionBlueprint + { + public new SliderBodyPiece BodyPiece => base.BodyPiece; + public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay; + public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay; + public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; + + public TestSliderBlueprint(Slider slider) + : base(slider) + { + } + + protected override SliderCircleOverlay CreateCircleOverlay(Slider slider, SliderPosition position) => new TestSliderCircleOverlay(slider, position); + } + + private class TestSliderCircleOverlay : SliderCircleOverlay + { + public new HitCirclePiece CirclePiece => base.CirclePiece; + + public TestSliderCircleOverlay(Slider slider, SliderPosition position) + : base(slider, position) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs new file mode 100644 index 0000000000..ce529f2a88 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderLengthValidity.cs @@ -0,0 +1,198 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + [TestFixture] + public class TestSceneSliderLengthValidity : TestSceneOsuEditor + { + private OsuPlayfield playfield; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(Ruleset.Value, false); + + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("get playfield", () => playfield = Editor.ChildrenOfType().First()); + AddStep("seek to first timing point", () => EditorClock.Seek(Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.First().Time)); + } + + [Test] + public void TestDraggingStartingPointRemainsValid() + { + Slider slider = null; + + AddStep("Add slider", () => + { + slider = new Slider { StartTime = EditorClock.CurrentTime, Position = new Vector2(300) }; + + PathControlPoint[] points = + { + new PathControlPoint(new Vector2(0), PathType.Linear), + new PathControlPoint(new Vector2(100, 0)), + }; + + slider.Path = new SliderPath(points); + EditorBeatmap.Add(slider); + }); + + AddAssert("ensure object placed", () => EditorBeatmap.HitObjects.Count == 1); + + moveMouse(new Vector2(300)); + AddStep("select slider", () => InputManager.Click(MouseButton.Left)); + + double distanceBefore = 0; + + AddStep("store distance", () => distanceBefore = slider.Path.Distance); + + moveMouse(new Vector2(300, 300)); + + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + moveMouse(new Vector2(350, 300)); + moveMouse(new Vector2(400, 300)); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("slider length shrunk", () => slider.Path.Distance < distanceBefore); + AddAssert("ensure slider still has valid length", () => slider.Path.Distance > 0); + } + + [Test] + public void TestDraggingEndingPointRemainsValid() + { + Slider slider = null; + + AddStep("Add slider", () => + { + slider = new Slider { StartTime = EditorClock.CurrentTime, Position = new Vector2(300) }; + + PathControlPoint[] points = + { + new PathControlPoint(new Vector2(0), PathType.Linear), + new PathControlPoint(new Vector2(100, 0)), + }; + + slider.Path = new SliderPath(points); + EditorBeatmap.Add(slider); + }); + + AddAssert("ensure object placed", () => EditorBeatmap.HitObjects.Count == 1); + + moveMouse(new Vector2(300)); + AddStep("select slider", () => InputManager.Click(MouseButton.Left)); + + double distanceBefore = 0; + + AddStep("store distance", () => distanceBefore = slider.Path.Distance); + + moveMouse(new Vector2(400, 300)); + + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + moveMouse(new Vector2(350, 300)); + moveMouse(new Vector2(300, 300)); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("slider length shrunk", () => slider.Path.Distance < distanceBefore); + AddAssert("ensure slider still has valid length", () => slider.Path.Distance > 0); + } + + /// + /// If a control point is deleted which results in the slider becoming so short it can't exist, + /// for simplicity delete the slider rather than having it in an invalid state. + /// + /// Eventually we may need to change this, based on user feedback. I think it's likely enough of + /// an edge case that we won't get many complaints, though (and there's always the undo button). + /// + [Test] + public void TestDeletingPointCausesSliderDeletion() + { + AddStep("Add slider", () => + { + Slider slider = new Slider { StartTime = EditorClock.CurrentTime, Position = new Vector2(300) }; + + PathControlPoint[] points = + { + new PathControlPoint(new Vector2(0), PathType.PerfectCurve), + new PathControlPoint(new Vector2(100, 0)), + new PathControlPoint(new Vector2(0, 10)) + }; + + slider.Path = new SliderPath(points); + EditorBeatmap.Add(slider); + }); + + AddAssert("ensure object placed", () => EditorBeatmap.HitObjects.Count == 1); + + AddStep("select slider", () => InputManager.Click(MouseButton.Left)); + + moveMouse(new Vector2(400, 300)); + AddStep("delete second point", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Click(MouseButton.Right); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + AddAssert("ensure object deleted", () => EditorBeatmap.HitObjects.Count == 0); + } + + /// + /// If a scale operation is performed where a single slider is the only thing selected, the path's shape will change. + /// If the scale results in the path becoming too short, further mouse movement in the same direction will not change the shape. + /// + [Test] + public void TestScalingSliderTooSmallRemainsValid() + { + Slider slider = null; + + AddStep("Add slider", () => + { + slider = new Slider { StartTime = EditorClock.CurrentTime, Position = new Vector2(300, 200) }; + + PathControlPoint[] points = + { + new PathControlPoint(new Vector2(0), PathType.Linear), + new PathControlPoint(new Vector2(0, 50)), + new PathControlPoint(new Vector2(0, 100)) + }; + + slider.Path = new SliderPath(points); + EditorBeatmap.Add(slider); + }); + + AddAssert("ensure object placed", () => EditorBeatmap.HitObjects.Count == 1); + + moveMouse(new Vector2(300)); + AddStep("select slider", () => InputManager.Click(MouseButton.Left)); + + double distanceBefore = 0; + + AddStep("store distance", () => distanceBefore = slider.Path.Distance); + + AddStep("move mouse to handle", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().Skip(1).First())); + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + moveMouse(new Vector2(300, 300)); + moveMouse(new Vector2(300, 250)); + moveMouse(new Vector2(300, 200)); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("slider length shrunk", () => slider.Path.Distance < distanceBefore); + AddAssert("ensure slider still has valid length", () => slider.Path.Distance > 0); + } + + private void moveMouse(Vector2 pos) => + AddStep($"move mouse to {pos}", () => InputManager.MoveMouseTo(playfield.ToScreenSpace(pos))); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs index 67a2e5a47c..8235e1bc79 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs @@ -41,9 +41,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor addClickStep(MouseButton.Left); addClickStep(MouseButton.Right); - assertPlaced(true); - assertLength(0); - assertControlPointType(0, PathType.Linear); + assertPlaced(false); } [Test] @@ -276,6 +274,104 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertControlPointType(0, PathType.Linear); } + [Test] + public void TestPlacePerfectCurveSegmentAlmostLinearlyExterior() + { + Vector2 startPosition = new Vector2(200); + + addMovementStep(startPosition); + addClickStep(MouseButton.Left); + + addMovementStep(startPosition + new Vector2(300, 0)); + addClickStep(MouseButton.Left); + + addMovementStep(startPosition + new Vector2(150, 0.1f)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointType(0, PathType.Bezier); + } + + [Test] + public void TestPlacePerfectCurveSegmentRecovery() + { + Vector2 startPosition = new Vector2(200); + + addMovementStep(startPosition); + addClickStep(MouseButton.Left); + + addMovementStep(startPosition + new Vector2(300, 0)); + addClickStep(MouseButton.Left); + + addMovementStep(startPosition + new Vector2(150, 0.1f)); // Should convert to bezier + addMovementStep(startPosition + new Vector2(400.0f, 50.0f)); // Should convert back to perfect + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointType(0, PathType.PerfectCurve); + } + + [Test] + public void TestPlacePerfectCurveSegmentLarge() + { + Vector2 startPosition = new Vector2(400); + + addMovementStep(startPosition); + addClickStep(MouseButton.Left); + + addMovementStep(startPosition + new Vector2(220, 220)); + addClickStep(MouseButton.Left); + + // Playfield dimensions are 640 x 480. + // So a 440 x 440 bounding box should be ok. + addMovementStep(startPosition + new Vector2(-220, 220)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointType(0, PathType.PerfectCurve); + } + + [Test] + public void TestPlacePerfectCurveSegmentTooLarge() + { + Vector2 startPosition = new Vector2(480, 200); + + addMovementStep(startPosition); + addClickStep(MouseButton.Left); + + addMovementStep(startPosition + new Vector2(400, 400)); + addClickStep(MouseButton.Left); + + // Playfield dimensions are 640 x 480. + // So an 800 * 800 bounding box area should not be ok. + addMovementStep(startPosition + new Vector2(-400, 400)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointType(0, PathType.Bezier); + } + + [Test] + public void TestPlacePerfectCurveSegmentCompleteArc() + { + addMovementStep(new Vector2(400)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(600, 400)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 410)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointType(0, PathType.PerfectCurve); + } + private void addMovementStep(Vector2 position) => AddStep($"move mouse to {position}", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(position))); private void addClickStep(MouseButton button) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs index f6e1be693b..0d828a79c8 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs @@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); Add(drawableObject = new DrawableSlider(slider)); - AddBlueprint(blueprint = new TestSliderBlueprint(drawableObject)); + AddBlueprint(blueprint = new TestSliderBlueprint(slider), drawableObject); }); [Test] @@ -174,10 +174,10 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddAssert("body positioned correctly", () => blueprint.BodyPiece.Position == slider.StackedPosition); AddAssert("head positioned correctly", - () => Precision.AlmostEquals(blueprint.HeadBlueprint.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.HeadCircle.ScreenSpaceDrawQuad.Centre)); + () => Precision.AlmostEquals(blueprint.HeadOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.HeadCircle.ScreenSpaceDrawQuad.Centre)); AddAssert("tail positioned correctly", - () => Precision.AlmostEquals(blueprint.TailBlueprint.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre)); + () => Precision.AlmostEquals(blueprint.TailOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre)); } private void moveMouseToControlPoint(int index) @@ -195,23 +195,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor private class TestSliderBlueprint : SliderSelectionBlueprint { public new SliderBodyPiece BodyPiece => base.BodyPiece; - public new TestSliderCircleBlueprint HeadBlueprint => (TestSliderCircleBlueprint)base.HeadBlueprint; - public new TestSliderCircleBlueprint TailBlueprint => (TestSliderCircleBlueprint)base.TailBlueprint; + public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay; + public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay; public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; - public TestSliderBlueprint(DrawableSlider slider) + public TestSliderBlueprint(Slider slider) : base(slider) { } - protected override SliderCircleSelectionBlueprint CreateCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) => new TestSliderCircleBlueprint(slider, position); + protected override SliderCircleOverlay CreateCircleOverlay(Slider slider, SliderPosition position) => new TestSliderCircleOverlay(slider, position); } - private class TestSliderCircleBlueprint : SliderCircleSelectionBlueprint + private class TestSliderCircleOverlay : SliderCircleOverlay { public new HitCirclePiece CirclePiece => base.CirclePiece; - public TestSliderCircleBlueprint(DrawableSlider slider, SliderPosition position) + public TestSliderCircleOverlay(Slider slider, SliderPosition position) : base(slider, position) { } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs index 4248f68a60..5007841805 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs @@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor Child = drawableSpinner = new DrawableSpinner(spinner) }); - AddBlueprint(new SpinnerSelectionBlueprint(drawableSpinner) { Size = new Vector2(0.5f) }); + AddBlueprint(new SpinnerSelectionBlueprint(spinner) { Size = new Vector2(0.5f) }, drawableSpinner); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs new file mode 100644 index 0000000000..e29a67c770 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSliderScaling.cs @@ -0,0 +1,72 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + [TestFixture] + public class TestSliderScaling : TestSceneOsuEditor + { + private OsuPlayfield playfield; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(Ruleset.Value, false); + + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("get playfield", () => playfield = Editor.ChildrenOfType().First()); + AddStep("seek to first timing point", () => EditorClock.Seek(Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.First().Time)); + } + + [Test] + public void TestScalingLinearSlider() + { + Slider slider = null; + + AddStep("Add slider", () => + { + slider = new Slider { StartTime = EditorClock.CurrentTime, Position = new Vector2(300) }; + + PathControlPoint[] points = + { + new PathControlPoint(new Vector2(0), PathType.Linear), + new PathControlPoint(new Vector2(100, 0)), + }; + + slider.Path = new SliderPath(points); + EditorBeatmap.Add(slider); + }); + + AddAssert("ensure object placed", () => EditorBeatmap.HitObjects.Count == 1); + + moveMouse(new Vector2(300)); + AddStep("select slider", () => InputManager.Click(MouseButton.Left)); + + double distanceBefore = 0; + + AddStep("store distance", () => distanceBefore = slider.Path.Distance); + + AddStep("move mouse to handle", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().Skip(1).First())); + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + moveMouse(new Vector2(300, 300)); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("slider length shrunk", () => slider.Path.Distance < distanceBefore); + } + + private void moveMouse(Vector2 pos) => + AddStep($"move mouse to {pos}", () => InputManager.MoveMouseTo(playfield.ToScreenSpace(pos))); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs index 856b6554b9..0ba775e5c7 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods private void runSpmTest(Mod mod) { - SpinnerSpmCounter spmCounter = null; + SpinnerSpmCalculator spmCalculator = null; CreateModTest(new ModTestData { @@ -53,13 +53,13 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods PassCondition = () => Player.ScoreProcessor.JudgedHits >= 1 }); - AddUntilStep("fetch SPM counter", () => + AddUntilStep("fetch SPM calculator", () => { - spmCounter = this.ChildrenOfType().SingleOrDefault(); - return spmCounter != null; + spmCalculator = this.ChildrenOfType().SingleOrDefault(); + return spmCalculator != null; }); - AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCounter.SpinsPerMinute, 477, 5)); + AddUntilStep("SPM is correct", () => Precision.AlmostEquals(spmCalculator.Result.Value, 477, 5)); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs index 7df5ca0f7c..24e69703a6 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs @@ -47,8 +47,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Beatmap = singleSpinnerBeatmap, PassCondition = () => { - var counter = Player.ChildrenOfType().SingleOrDefault(); - return counter != null && Precision.AlmostEquals(counter.SpinsPerMinute, 286, 1); + var counter = Player.ChildrenOfType().SingleOrDefault(); + return counter != null && Precision.AlmostEquals(counter.Result.Value, 286, 1); } }); } diff --git a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs index 7d32895083..5f44e1b6b6 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs @@ -23,6 +23,7 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase("repeat-slider")] [TestCase("uneven-repeat-slider")] [TestCase("old-stacking")] + [TestCase("multi-segment-slider")] public void Test(string name) => base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index a365ea10d4..afd94f4570 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,13 +15,13 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.9311451172608853d, "diffcalc-test")] - [TestCase(1.0736587013228804d, "zero-length-sliders")] + [TestCase(6.9311451172574934d, "diffcalc-test")] + [TestCase(1.0736586907780401d, "zero-length-sliders")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(8.6228371119393064d, "diffcalc-test")] - [TestCase(1.2864585434597433d, "zero-length-sliders")] + [TestCase(8.7212283220412345d, "diffcalc-test")] + [TestCase(1.3212137158641493d, "zero-length-sliders")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new OsuModDoubleTime()); diff --git a/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs index cad98185ce..233aaf2ed9 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Tests get { if (content == null) - base.Content.Add(content = new OsuInputManager(new RulesetInfo { ID = 0 })); + base.Content.Add(content = new OsuInputManager(new OsuRuleset().RulesetInfo)); return content; } diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor.png new file mode 100755 index 0000000000..fe305468fe Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursor.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursortrail.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursortrail.png new file mode 100755 index 0000000000..f3327dc92f Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/cursortrail.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-0.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-0.png new file mode 100644 index 0000000000..8304617d8c Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-0.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-1.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-1.png new file mode 100644 index 0000000000..c3b85eb873 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-1.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-2.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-2.png new file mode 100644 index 0000000000..7f65eb7ca7 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-2.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-3.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-3.png new file mode 100644 index 0000000000..82bec3babe Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-3.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-4.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-4.png new file mode 100644 index 0000000000..5e38c75a9d Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-4.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-5.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-5.png new file mode 100644 index 0000000000..a562d9f2ac Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-5.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-6.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-6.png new file mode 100644 index 0000000000..b4cf81f26e Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-6.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-7.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-7.png new file mode 100644 index 0000000000..a23f5379b2 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-7.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-8.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-8.png new file mode 100644 index 0000000000..430b18509d Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-8.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-9.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-9.png new file mode 100644 index 0000000000..add1202c31 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-9.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-comma.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-comma.png new file mode 100644 index 0000000000..f68d32957f Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-comma.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-dot.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-dot.png new file mode 100644 index 0000000000..80c39b8745 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-dot.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-percent.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-percent.png new file mode 100644 index 0000000000..fc750abc7e Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-percent.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-x.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-x.png new file mode 100644 index 0000000000..779773f8bd Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/score-x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini index 5369de24e9..89bcd68343 100644 --- a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini +++ b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini @@ -1,2 +1,6 @@ [General] -Version: 1.0 \ No newline at end of file +Version: 1.0 + +[Fonts] +HitCircleOverlap: 3 +ScoreOverlap: 3 \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-rpm.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-rpm.png new file mode 100644 index 0000000000..73753554f7 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-rpm.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index e2d9f144c0..0ba97fac54 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -78,7 +78,7 @@ namespace osu.Game.Rulesets.Osu.Tests RelativeSizeAxes = Axes.Both; } - public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException(); + public Drawable GetDrawableComponent(ISkinComponent component) => null; public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { @@ -98,9 +98,9 @@ namespace osu.Game.Rulesets.Osu.Tests return null; } - public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public ISample GetSample(ISampleInfo sampleInfo) => null; - public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + public IBindable GetConfig(TLookup lookup) => null; public event Action SourceChanged { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs index e4158d8f07..7821ae9cf0 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestHitLightingDisabled() { - AddStep("hit lighting disabled", () => config.Set(OsuSetting.HitLighting, false)); + AddStep("hit lighting disabled", () => config.SetValue(OsuSetting.HitLighting, false)); showResult(HitResult.Great); @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestHitLightingEnabled() { - AddStep("hit lighting enabled", () => config.Set(OsuSetting.HitLighting, true)); + AddStep("hit lighting enabled", () => config.SetValue(OsuSetting.HitLighting, true)); showResult(HitResult.Great); @@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Osu.Tests { int poolIndex = 0; - SetContents(() => + SetContents(_ => { DrawablePool pool; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index 461779b185..a95159ce4c 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -4,13 +4,22 @@ using System; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input; using osu.Framework.Testing.Input; using osu.Framework.Utils; +using osu.Game.Audio; using osu.Game.Configuration; +using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Screens.Play; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Tests @@ -21,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Tests [Cached] private GameplayBeatmap gameplayBeatmap; - private ClickingCursorContainer lastContainer; + private OsuCursorContainer lastContainer; [Resolved] private OsuConfigManager config { get; set; } @@ -46,14 +55,12 @@ namespace osu.Game.Rulesets.Osu.Tests AddSliderStep("circle size", 0f, 10f, 0f, val => { - config.Set(OsuSetting.AutoCursorSize, true); + config.SetValue(OsuSetting.AutoCursorSize, true); gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = val; - Scheduler.AddOnce(recreate); + Scheduler.AddOnce(() => loadContent(false)); }); - AddStep("test cursor container", recreate); - - void recreate() => SetContents(() => new OsuInputManager(new OsuRuleset().RulesetInfo) { Child = new OsuCursorContainer() }); + AddStep("test cursor container", () => loadContent(false)); } [TestCase(1, 1)] @@ -64,36 +71,64 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase(10, 1.5f)] public void TestSizing(int circleSize, float userScale) { - AddStep($"set user scale to {userScale}", () => config.Set(OsuSetting.GameplayCursorSize, userScale)); + AddStep($"set user scale to {userScale}", () => config.SetValue(OsuSetting.GameplayCursorSize, userScale)); AddStep($"adjust cs to {circleSize}", () => gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = circleSize); - AddStep("turn on autosizing", () => config.Set(OsuSetting.AutoCursorSize, true)); + AddStep("turn on autosizing", () => config.SetValue(OsuSetting.AutoCursorSize, true)); - AddStep("load content", loadContent); + AddStep("load content", () => loadContent()); AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == OsuCursorContainer.GetScaleForCircleSize(circleSize) * userScale); - AddStep("set user scale to 1", () => config.Set(OsuSetting.GameplayCursorSize, 1f)); + AddStep("set user scale to 1", () => config.SetValue(OsuSetting.GameplayCursorSize, 1f)); AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == OsuCursorContainer.GetScaleForCircleSize(circleSize)); - AddStep("turn off autosizing", () => config.Set(OsuSetting.AutoCursorSize, false)); + AddStep("turn off autosizing", () => config.SetValue(OsuSetting.AutoCursorSize, false)); AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == 1); - AddStep($"set user scale to {userScale}", () => config.Set(OsuSetting.GameplayCursorSize, userScale)); + AddStep($"set user scale to {userScale}", () => config.SetValue(OsuSetting.GameplayCursorSize, userScale)); AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == userScale); } - private void loadContent() + [Test] + public void TestTopLeftOrigin() { - SetContents(() => new MovingCursorInputManager + AddStep("load content", () => loadContent(false, () => new SkinProvidingContainer(new TopLeftCursorSkin()))); + } + + private void loadContent(bool automated = true, Func skinProvider = null) + { + SetContents(_ => { - Child = lastContainer = new ClickingCursorContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - } + var inputManager = automated ? (InputManager)new MovingCursorInputManager() : new OsuInputManager(new OsuRuleset().RulesetInfo); + var skinContainer = skinProvider?.Invoke() ?? new SkinProvidingContainer(null); + + lastContainer = automated ? new ClickingCursorContainer() : new OsuCursorContainer(); + + return inputManager.WithChild(skinContainer.WithChild(lastContainer)); }); } + private class TopLeftCursorSkin : ISkin + { + public Drawable GetDrawableComponent(ISkinComponent component) => null; + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; + public ISample GetSample(ISampleInfo sampleInfo) => null; + + public IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case OsuSkinConfiguration osuLookup: + if (osuLookup == OsuSkinConfiguration.CursorCentre) + return SkinUtils.As(new BindableBool(false)); + + break; + } + + return null; + } + } + private class ClickingCursorContainer : OsuCursorContainer { private bool pressed; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs index 1278a0ff2d..58e46b6687 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs @@ -23,18 +23,18 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestVariousHitCircles() { - AddStep("Miss Big Single", () => SetContents(() => testSingle(2))); - AddStep("Miss Medium Single", () => SetContents(() => testSingle(5))); - AddStep("Miss Small Single", () => SetContents(() => testSingle(7))); - AddStep("Hit Big Single", () => SetContents(() => testSingle(2, true))); - AddStep("Hit Medium Single", () => SetContents(() => testSingle(5, true))); - AddStep("Hit Small Single", () => SetContents(() => testSingle(7, true))); - AddStep("Miss Big Stream", () => SetContents(() => testStream(2))); - AddStep("Miss Medium Stream", () => SetContents(() => testStream(5))); - AddStep("Miss Small Stream", () => SetContents(() => testStream(7))); - AddStep("Hit Big Stream", () => SetContents(() => testStream(2, true))); - AddStep("Hit Medium Stream", () => SetContents(() => testStream(5, true))); - AddStep("Hit Small Stream", () => SetContents(() => testStream(7, true))); + AddStep("Miss Big Single", () => SetContents(_ => testSingle(2))); + AddStep("Miss Medium Single", () => SetContents(_ => testSingle(5))); + AddStep("Miss Small Single", () => SetContents(_ => testSingle(7))); + AddStep("Hit Big Single", () => SetContents(_ => testSingle(2, true))); + AddStep("Hit Medium Single", () => SetContents(_ => testSingle(5, true))); + AddStep("Hit Small Single", () => SetContents(_ => testSingle(7, true))); + AddStep("Miss Big Stream", () => SetContents(_ => testStream(2))); + AddStep("Miss Medium Stream", () => SetContents(_ => testStream(5))); + AddStep("Miss Small Stream", () => SetContents(_ => testStream(7))); + AddStep("Hit Big Stream", () => SetContents(_ => testStream(2, true))); + AddStep("Hit Medium Stream", () => SetContents(_ => testStream(5, true))); + AddStep("Hit Small Stream", () => SetContents(_ => testStream(7, true))); } private Drawable testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs index 5fc1082743..8b3fead366 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Tests { Position = new Vector2(128, 128), ComboIndex = 1, - }), null)); + }))); } private HitCircle prepareObject(HitCircle circle) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs index 0649989dc0..1fdcd73dde 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Tests hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - Child = new SkinProvidingContainer(new DefaultSkin()) + Child = new SkinProvidingContainer(new DefaultSkin(null)) { RelativeSizeAxes = Axes.Both, Child = drawableHitCircle = new DrawableHitCircle(hitCircle) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs index c26419b0e8..56307861f1 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs @@ -30,28 +30,28 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase(true, false)] [TestCase(false, true)] [TestCase(false, false)] - public override void TestBeatmapComboColours(bool userHasCustomColours, bool useBeatmapSkin) + public void TestBeatmapComboColours(bool userHasCustomColours, bool useBeatmapSkin) { - TestBeatmap = new OsuCustomSkinWorkingBeatmap(audio, true); - base.TestBeatmapComboColours(userHasCustomColours, useBeatmapSkin); + PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, true)); + ConfigureTest(useBeatmapSkin, true, userHasCustomColours); AddAssert("is beatmap skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(TestBeatmapSkin.Colours)); } [TestCase(true)] [TestCase(false)] - public override void TestBeatmapComboColoursOverride(bool useBeatmapSkin) + public void TestBeatmapComboColoursOverride(bool useBeatmapSkin) { - TestBeatmap = new OsuCustomSkinWorkingBeatmap(audio, true); - base.TestBeatmapComboColoursOverride(useBeatmapSkin); + PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, true)); + ConfigureTest(useBeatmapSkin, false, true); AddAssert("is user custom skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(TestSkin.Colours)); } [TestCase(true)] [TestCase(false)] - public override void TestBeatmapComboColoursOverrideWithDefaultColours(bool useBeatmapSkin) + public void TestBeatmapComboColoursOverrideWithDefaultColours(bool useBeatmapSkin) { - TestBeatmap = new OsuCustomSkinWorkingBeatmap(audio, true); - base.TestBeatmapComboColoursOverrideWithDefaultColours(useBeatmapSkin); + PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, true)); + ConfigureTest(useBeatmapSkin, false, false); AddAssert("is default user skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(SkinConfiguration.DefaultComboColours)); } @@ -59,10 +59,10 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase(false, true)] [TestCase(true, false)] [TestCase(false, false)] - public override void TestBeatmapNoComboColours(bool useBeatmapSkin, bool useBeatmapColour) + public void TestBeatmapNoComboColours(bool useBeatmapSkin, bool useBeatmapColour) { - TestBeatmap = new OsuCustomSkinWorkingBeatmap(audio, false); - base.TestBeatmapNoComboColours(useBeatmapSkin, useBeatmapColour); + PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, false)); + ConfigureTest(useBeatmapSkin, useBeatmapColour, false); AddAssert("is default user skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(SkinConfiguration.DefaultComboColours)); } @@ -70,10 +70,10 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase(false, true)] [TestCase(true, false)] [TestCase(false, false)] - public override void TestBeatmapNoComboColoursSkinOverride(bool useBeatmapSkin, bool useBeatmapColour) + public void TestBeatmapNoComboColoursSkinOverride(bool useBeatmapSkin, bool useBeatmapColour) { - TestBeatmap = new OsuCustomSkinWorkingBeatmap(audio, false); - base.TestBeatmapNoComboColoursSkinOverride(useBeatmapSkin, useBeatmapColour); + PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, false)); + ConfigureTest(useBeatmapSkin, useBeatmapColour, true); AddAssert("is custom user skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(TestSkin.Colours)); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs index 8dbb48c048..6c6f05c5c5 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -42,10 +42,10 @@ namespace osu.Game.Rulesets.Osu.Tests { AddStep("enable user provider", () => testUserSkin.Enabled = true); - AddStep("enable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, true)); + AddStep("enable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, true)); checkNextHitObject("beatmap"); - AddStep("disable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, false)); + AddStep("disable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, false)); checkNextHitObject("user"); AddStep("disable user provider", () => testUserSkin.Enabled = false); @@ -57,20 +57,20 @@ namespace osu.Game.Rulesets.Osu.Tests { AddStep("enable user provider", () => testUserSkin.Enabled = true); - AddStep("enable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, true)); - AddStep("enable beatmap colours", () => LocalConfig.Set(OsuSetting.BeatmapColours, true)); + AddStep("enable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, true)); + AddStep("enable beatmap colours", () => LocalConfig.SetValue(OsuSetting.BeatmapColours, true)); checkNextHitObject("beatmap"); - AddStep("enable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, true)); - AddStep("disable beatmap colours", () => LocalConfig.Set(OsuSetting.BeatmapColours, false)); + AddStep("enable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, true)); + AddStep("disable beatmap colours", () => LocalConfig.SetValue(OsuSetting.BeatmapColours, false)); checkNextHitObject("beatmap"); - AddStep("disable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, false)); - AddStep("enable beatmap colours", () => LocalConfig.Set(OsuSetting.BeatmapColours, true)); + AddStep("disable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, false)); + AddStep("enable beatmap colours", () => LocalConfig.SetValue(OsuSetting.BeatmapColours, true)); checkNextHitObject("user"); - AddStep("disable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, false)); - AddStep("disable beatmap colours", () => LocalConfig.Set(OsuSetting.BeatmapColours, false)); + AddStep("disable beatmap skin", () => LocalConfig.SetValue(OsuSetting.BeatmapSkins, false)); + AddStep("disable beatmap colours", () => LocalConfig.SetValue(OsuSetting.BeatmapColours, false)); checkNextHitObject("user"); AddStep("disable user provider", () => testUserSkin.Enabled = false); @@ -162,7 +162,7 @@ namespace osu.Game.Rulesets.Osu.Tests public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; - public Sample GetSample(ISampleInfo sampleInfo) => null; + public ISample GetSample(ISampleInfo sampleInfo) => null; public TValue GetValue(Func query) where TConfiguration : SkinConfiguration => default; public IBindable GetConfig(TLookup lookup) => null; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index d40484f5ed..fc5fcf2358 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -30,54 +30,54 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestVariousSliders() { - AddStep("Big Single", () => SetContents(() => testSimpleBig())); - AddStep("Medium Single", () => SetContents(() => testSimpleMedium())); - AddStep("Small Single", () => SetContents(() => testSimpleSmall())); - AddStep("Big 1 Repeat", () => SetContents(() => testSimpleBig(1))); - AddStep("Medium 1 Repeat", () => SetContents(() => testSimpleMedium(1))); - AddStep("Small 1 Repeat", () => SetContents(() => testSimpleSmall(1))); - AddStep("Big 2 Repeats", () => SetContents(() => testSimpleBig(2))); - AddStep("Medium 2 Repeats", () => SetContents(() => testSimpleMedium(2))); - AddStep("Small 2 Repeats", () => SetContents(() => testSimpleSmall(2))); + AddStep("Big Single", () => SetContents(_ => testSimpleBig())); + AddStep("Medium Single", () => SetContents(_ => testSimpleMedium())); + AddStep("Small Single", () => SetContents(_ => testSimpleSmall())); + AddStep("Big 1 Repeat", () => SetContents(_ => testSimpleBig(1))); + AddStep("Medium 1 Repeat", () => SetContents(_ => testSimpleMedium(1))); + AddStep("Small 1 Repeat", () => SetContents(_ => testSimpleSmall(1))); + AddStep("Big 2 Repeats", () => SetContents(_ => testSimpleBig(2))); + AddStep("Medium 2 Repeats", () => SetContents(_ => testSimpleMedium(2))); + AddStep("Small 2 Repeats", () => SetContents(_ => testSimpleSmall(2))); - AddStep("Slow Slider", () => SetContents(testSlowSpeed)); // slow long sliders take ages already so no repeat steps - AddStep("Slow Short Slider", () => SetContents(() => testShortSlowSpeed())); - AddStep("Slow Short Slider 1 Repeats", () => SetContents(() => testShortSlowSpeed(1))); - AddStep("Slow Short Slider 2 Repeats", () => SetContents(() => testShortSlowSpeed(2))); + AddStep("Slow Slider", () => SetContents(_ => testSlowSpeed())); // slow long sliders take ages already so no repeat steps + AddStep("Slow Short Slider", () => SetContents(_ => testShortSlowSpeed())); + AddStep("Slow Short Slider 1 Repeats", () => SetContents(_ => testShortSlowSpeed(1))); + AddStep("Slow Short Slider 2 Repeats", () => SetContents(_ => testShortSlowSpeed(2))); - AddStep("Fast Slider", () => SetContents(() => testHighSpeed())); - AddStep("Fast Slider 1 Repeat", () => SetContents(() => testHighSpeed(1))); - AddStep("Fast Slider 2 Repeats", () => SetContents(() => testHighSpeed(2))); - AddStep("Fast Short Slider", () => SetContents(() => testShortHighSpeed())); - AddStep("Fast Short Slider 1 Repeat", () => SetContents(() => testShortHighSpeed(1))); - AddStep("Fast Short Slider 2 Repeats", () => SetContents(() => testShortHighSpeed(2))); - AddStep("Fast Short Slider 6 Repeats", () => SetContents(() => testShortHighSpeed(6))); + AddStep("Fast Slider", () => SetContents(_ => testHighSpeed())); + AddStep("Fast Slider 1 Repeat", () => SetContents(_ => testHighSpeed(1))); + AddStep("Fast Slider 2 Repeats", () => SetContents(_ => testHighSpeed(2))); + AddStep("Fast Short Slider", () => SetContents(_ => testShortHighSpeed())); + AddStep("Fast Short Slider 1 Repeat", () => SetContents(_ => testShortHighSpeed(1))); + AddStep("Fast Short Slider 2 Repeats", () => SetContents(_ => testShortHighSpeed(2))); + AddStep("Fast Short Slider 6 Repeats", () => SetContents(_ => testShortHighSpeed(6))); - AddStep("Perfect Curve", () => SetContents(() => testPerfect())); - AddStep("Perfect Curve 1 Repeat", () => SetContents(() => testPerfect(1))); - AddStep("Perfect Curve 2 Repeats", () => SetContents(() => testPerfect(2))); + AddStep("Perfect Curve", () => SetContents(_ => testPerfect())); + AddStep("Perfect Curve 1 Repeat", () => SetContents(_ => testPerfect(1))); + AddStep("Perfect Curve 2 Repeats", () => SetContents(_ => testPerfect(2))); - AddStep("Linear Slider", () => SetContents(() => testLinear())); - AddStep("Linear Slider 1 Repeat", () => SetContents(() => testLinear(1))); - AddStep("Linear Slider 2 Repeats", () => SetContents(() => testLinear(2))); + AddStep("Linear Slider", () => SetContents(_ => testLinear())); + AddStep("Linear Slider 1 Repeat", () => SetContents(_ => testLinear(1))); + AddStep("Linear Slider 2 Repeats", () => SetContents(_ => testLinear(2))); - AddStep("Bezier Slider", () => SetContents(() => testBezier())); - AddStep("Bezier Slider 1 Repeat", () => SetContents(() => testBezier(1))); - AddStep("Bezier Slider 2 Repeats", () => SetContents(() => testBezier(2))); + AddStep("Bezier Slider", () => SetContents(_ => testBezier())); + AddStep("Bezier Slider 1 Repeat", () => SetContents(_ => testBezier(1))); + AddStep("Bezier Slider 2 Repeats", () => SetContents(_ => testBezier(2))); - AddStep("Linear Overlapping", () => SetContents(() => testLinearOverlapping())); - AddStep("Linear Overlapping 1 Repeat", () => SetContents(() => testLinearOverlapping(1))); - AddStep("Linear Overlapping 2 Repeats", () => SetContents(() => testLinearOverlapping(2))); + AddStep("Linear Overlapping", () => SetContents(_ => testLinearOverlapping())); + AddStep("Linear Overlapping 1 Repeat", () => SetContents(_ => testLinearOverlapping(1))); + AddStep("Linear Overlapping 2 Repeats", () => SetContents(_ => testLinearOverlapping(2))); - AddStep("Catmull Slider", () => SetContents(() => testCatmull())); - AddStep("Catmull Slider 1 Repeat", () => SetContents(() => testCatmull(1))); - AddStep("Catmull Slider 2 Repeats", () => SetContents(() => testCatmull(2))); + AddStep("Catmull Slider", () => SetContents(_ => testCatmull())); + AddStep("Catmull Slider 1 Repeat", () => SetContents(_ => testCatmull(1))); + AddStep("Catmull Slider 2 Repeats", () => SetContents(_ => testCatmull(2))); - AddStep("Big Single, Large StackOffset", () => SetContents(() => testSimpleBigLargeStackOffset())); - AddStep("Big 1 Repeat, Large StackOffset", () => SetContents(() => testSimpleBigLargeStackOffset(1))); + AddStep("Big Single, Large StackOffset", () => SetContents(_ => testSimpleBigLargeStackOffset())); + AddStep("Big 1 Repeat, Large StackOffset", () => SetContents(_ => testSimpleBigLargeStackOffset(1))); - AddStep("Distance Overflow", () => SetContents(() => testDistanceOverflow())); - AddStep("Distance Overflow 1 Repeat", () => SetContents(() => testDistanceOverflow(1))); + AddStep("Distance Overflow", () => SetContents(_ => testDistanceOverflow())); + AddStep("Distance Overflow 1 Repeat", () => SetContents(_ => testDistanceOverflow(1))); } [Test] diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs index aac6db60fe..e698766aac 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Tests new Vector2(300, 0), }), RepeatCount = 1 - }), null)); + }))); } [Test] diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 2cc031405e..590d159300 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -34,6 +34,18 @@ namespace osu.Game.Rulesets.Osu.Tests private List judgementResults; + [Test] + public void TestPressBothKeysSimultaneouslyAndReleaseOne() + { + performTest(new List + { + new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start }, + new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, + }); + + AddAssert("Tracking retained", assertMaxJudge); + } + /// /// Scenario: /// - Press a key before a slider starts diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index f697a77d94..b21b7a6f4a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -5,12 +5,14 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Tests @@ -27,16 +29,26 @@ namespace osu.Game.Rulesets.Osu.Tests public void TestVariousSpinners(bool autoplay) { string term = autoplay ? "Hit" : "Miss"; - AddStep($"{term} Big", () => SetContents(() => testSingle(2, autoplay))); - AddStep($"{term} Medium", () => SetContents(() => testSingle(5, autoplay))); - AddStep($"{term} Small", () => SetContents(() => testSingle(7, autoplay))); + AddStep($"{term} Big", () => SetContents(_ => testSingle(2, autoplay))); + AddStep($"{term} Medium", () => SetContents(_ => testSingle(5, autoplay))); + AddStep($"{term} Small", () => SetContents(_ => testSingle(7, autoplay))); + } + + [Test] + public void TestSpinningSamplePitchShift() + { + AddStep("Add spinner", () => SetContents(_ => testSingle(5, true, 4000))); + AddUntilStep("Pitch starts low", () => getSpinningSample().Frequency.Value < 0.8); + AddUntilStep("Pitch increases", () => getSpinningSample().Frequency.Value > 0.8); + + PausableSkinnableSound getSpinningSample() => drawableSpinner.ChildrenOfType().FirstOrDefault(s => s.Samples.Any(i => i.LookupNames.Any(l => l.Contains("spinnerspin")))); } [TestCase(false)] [TestCase(true)] public void TestLongSpinner(bool autoplay) { - AddStep("Very long spinner", () => SetContents(() => testSingle(5, autoplay, 4000))); + AddStep("Very long spinner", () => SetContents(_ => testSingle(5, autoplay, 4000))); AddUntilStep("Wait for completion", () => drawableSpinner.Result.HasResult); AddUntilStep("Check correct progress", () => drawableSpinner.Progress == (autoplay ? 1 : 0)); } @@ -45,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase(true)] public void TestSuperShortSpinner(bool autoplay) { - AddStep("Very short spinner", () => SetContents(() => testSingle(5, autoplay, 200))); + AddStep("Very short spinner", () => SetContents(_ => testSingle(5, autoplay, 200))); AddUntilStep("Wait for completion", () => drawableSpinner.Result.HasResult); AddUntilStep("Short spinner implicitly completes", () => drawableSpinner.Progress == 1); } @@ -93,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Tests { base.Update(); if (auto) - RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * 3)); + RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * 2)); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs index d7fbc7ac48..8c97c02049 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Tests Position = new Vector2(256, 192), ComboIndex = 1, Duration = 1000, - }), null)); + }))); AddAssert("rotation is reset", () => dho.Result.RateAdjustedRotation == 0); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index ac8d5c81bc..8ff21057b5 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Screens.Play; using osu.Game.Storyboards; using osu.Game.Tests.Visual; using osuTK; @@ -168,13 +169,13 @@ namespace osu.Game.Rulesets.Osu.Tests double estimatedSpm = 0; addSeekStep(1000); - AddStep("retrieve spm", () => estimatedSpm = drawableSpinner.SpmCounter.SpinsPerMinute); + AddStep("retrieve spm", () => estimatedSpm = drawableSpinner.SpinsPerMinute.Value); addSeekStep(2000); - AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpmCounter.SpinsPerMinute, estimatedSpm, 1.0)); + AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0)); addSeekStep(1000); - AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpmCounter.SpinsPerMinute, estimatedSpm, 1.0)); + AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0)); } [TestCase(0.5)] @@ -188,16 +189,16 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("retrieve spinner state", () => { expectedProgress = drawableSpinner.Progress; - expectedSpm = drawableSpinner.SpmCounter.SpinsPerMinute; + expectedSpm = drawableSpinner.SpinsPerMinute.Value; }); addSeekStep(0); - AddStep("adjust track rate", () => Player.GameplayClockContainer.UserPlaybackRate.Value = rate); + AddStep("adjust track rate", () => ((MasterGameplayClockContainer)Player.GameplayClockContainer).UserPlaybackRate.Value = rate); addSeekStep(1000); AddAssert("progress almost same", () => Precision.AlmostEquals(expectedProgress, drawableSpinner.Progress, 0.05)); - AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpmCounter.SpinsPerMinute, 2.0)); + AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 2.0)); } private Replay applyRateAdjustment(Replay scoreReplay, double rate) => new Replay diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj index b4c686ccea..ebe642803b 100644 --- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj +++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj @@ -2,8 +2,8 @@ - - + + diff --git a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs index e8272057f3..9589fd576f 100644 --- a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs @@ -17,10 +17,10 @@ namespace osu.Game.Rulesets.Osu.Configuration protected override void InitialiseDefaults() { base.InitialiseDefaults(); - Set(OsuRulesetSetting.SnakingInSliders, true); - Set(OsuRulesetSetting.SnakingOutSliders, true); - Set(OsuRulesetSetting.ShowCursorTrail, true); - Set(OsuRulesetSetting.PlayfieldBorderStyle, PlayfieldBorderStyle.None); + SetDefault(OsuRulesetSetting.SnakingInSliders, true); + SetDefault(OsuRulesetSetting.SnakingOutSliders, true); + SetDefault(OsuRulesetSetting.ShowCursorTrail, true); + SetDefault(OsuRulesetSetting.PlayfieldBorderStyle, PlayfieldBorderStyle.None); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 90cba13c7c..cb819ec090 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances. /// - public class Aim : Skill + public class Aim : StrainSkill { private const double angle_bonus_begin = Math.PI / 3; private const double timing_threshold = 107; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 200bc7997d..fbac080fc6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Represents the skill required to press keys with regards to keeping up with the speed at which objects need to be hit. /// - public class Speed : Skill + public class Speed : StrainSkill { private const double single_spacing_threshold = 125; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs index abbb54e3c1..b21a3e038e 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs @@ -15,8 +15,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles protected readonly HitCirclePiece CirclePiece; - public HitCircleSelectionBlueprint(DrawableHitCircle drawableCircle) - : base(drawableCircle) + public HitCircleSelectionBlueprint(HitCircle circle) + : base(circle) { InternalChild = CirclePiece = new HitCirclePiece(); } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs index 8dd550bb96..994c5cebeb 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs @@ -2,20 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Edit.Blueprints { - public abstract class OsuSelectionBlueprint : OverlaySelectionBlueprint + public abstract class OsuSelectionBlueprint : HitObjectSelectionBlueprint where T : OsuHitObject { - protected new T HitObject => (T)DrawableObject.HitObject; + protected new DrawableOsuHitObject DrawableObject => (DrawableOsuHitObject)base.DrawableObject; protected override bool AlwaysShowWhenSelected => true; - protected OsuSelectionBlueprint(DrawableHitObject drawableObject) - : base(drawableObject) + protected OsuSelectionBlueprint(T hitObject) + : base(hitObject) { } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs index e9838de63d..48e4db11ca 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -2,16 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; using osuTK; @@ -23,9 +29,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components /// /// A visualisation of a single in a . /// - public class PathControlPointPiece : BlueprintPiece + public class PathControlPointPiece : BlueprintPiece, IHasTooltip { public Action RequestSelection; + public List PointsInSegment; public readonly BindableBool IsSelected = new BindableBool(); public readonly PathControlPoint ControlPoint; @@ -52,6 +59,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components this.slider = slider; ControlPoint = controlPoint; + // we don't want to run the path type update on construction as it may inadvertently change the slider. + cachePoints(slider); + + slider.Path.Version.BindValueChanged(_ => + { + cachePoints(slider); + updatePathType(); + }); + controlPoint.Type.BindValueChanged(_ => updateMarkerDisplay()); Origin = Anchor.Centre; @@ -148,6 +164,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components protected override bool OnClick(ClickEvent e) => RequestSelection != null; private Vector2 dragStartPosition; + private PathType? dragPathType; protected override bool OnDragStart(DragStartEvent e) { @@ -157,6 +174,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components if (e.Button == MouseButton.Left) { dragStartPosition = ControlPoint.Position.Value; + dragPathType = PointsInSegment[0].Type.Value; + changeHandler?.BeginChange(); return true; } @@ -166,6 +185,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components protected override void OnDrag(DragEvent e) { + Vector2[] oldControlPoints = slider.Path.ControlPoints.Select(cp => cp.Position.Value).ToArray(); + var oldPosition = slider.Position; + var oldStartTime = slider.StartTime; + if (ControlPoint == slider.Path.ControlPoints[0]) { // Special handling for the head control point - the position of the slider changes which means the snapped position and time have to be taken into account @@ -182,10 +205,45 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else ControlPoint.Position.Value = dragStartPosition + (e.MousePosition - e.MouseDownPosition); + + if (!slider.Path.HasValidLength) + { + for (var i = 0; i < slider.Path.ControlPoints.Count; i++) + slider.Path.ControlPoints[i].Position.Value = oldControlPoints[i]; + + slider.Position = oldPosition; + slider.StartTime = oldStartTime; + return; + } + + // Maintain the path type in case it got defaulted to bezier at some point during the drag. + PointsInSegment[0].Type.Value = dragPathType; } protected override void OnDragEnd(DragEndEvent e) => changeHandler?.EndChange(); + private void cachePoints(Slider slider) => PointsInSegment = slider.Path.PointsInSegment(ControlPoint); + + /// + /// Handles correction of invalid path types. + /// + private void updatePathType() + { + if (ControlPoint.Type.Value != PathType.PerfectCurve) + return; + + if (PointsInSegment.Count > 3) + ControlPoint.Type.Value = PathType.Bezier; + + if (PointsInSegment.Count != 3) + return; + + ReadOnlySpan points = PointsInSegment.Select(p => p.Position.Value).ToArray(); + RectangleF boundingBox = PathApproximator.CircularArcBoundingBox(points); + if (boundingBox.Width >= 640 || boundingBox.Height >= 480) + ControlPoint.Type.Value = PathType.Bezier; + } + /// /// Updates the state of the circular control point marker. /// @@ -195,7 +253,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components markerRing.Alpha = IsSelected.Value ? 1 : 0; - Color4 colour = ControlPoint.Type.Value != null ? colours.Red : colours.Yellow; + Color4 colour = getColourFromNodeType(); if (IsHovered || IsSelected.Value) colour = colour.Lighten(1); @@ -203,5 +261,28 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components marker.Colour = colour; marker.Scale = new Vector2(slider.Scale); } + + private Color4 getColourFromNodeType() + { + if (!(ControlPoint.Type.Value is PathType pathType)) + return colours.Yellow; + + switch (pathType) + { + case PathType.Catmull: + return colours.Seafoam; + + case PathType.Bezier: + return colours.Pink; + + case PathType.PerfectCurve: + return colours.PurpleDark; + + default: + return colours.Red; + } + } + + public string TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index ce5dc4855e..c36768baba 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -153,6 +153,34 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } } + /// + /// Attempts to set the given control point piece to the given path type. + /// If that would fail, try to change the path such that it instead succeeds + /// in a UX-friendly way. + /// + /// The control point piece that we want to change the path type of. + /// The path type we want to assign to the given control point piece. + private void updatePathType(PathControlPointPiece piece, PathType? type) + { + int indexInSegment = piece.PointsInSegment.IndexOf(piece.ControlPoint); + + switch (type) + { + case PathType.PerfectCurve: + // Can't always create a circular arc out of 4 or more points, + // so we split the segment into one 3-point circular arc segment + // and one segment of the previous type. + int thirdPointIndex = indexInSegment + 2; + + if (piece.PointsInSegment.Count > thirdPointIndex + 1) + piece.PointsInSegment[thirdPointIndex].Type.Value = piece.PointsInSegment[0].Type.Value; + + break; + } + + piece.ControlPoint.Type.Value = type; + } + [Resolved(CanBeNull = true)] private IEditorChangeHandler changeHandler { get; set; } @@ -215,10 +243,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components int totalCount = Pieces.Count(p => p.IsSelected.Value); int countOfState = Pieces.Where(p => p.IsSelected.Value).Count(p => p.ControlPoint.Type.Value == type); - var item = new PathTypeMenuItem(type, () => + var item = new TernaryStateRadioMenuItem(type == null ? "Inherit" : type.ToString().Humanize(), MenuItemType.Standard, _ => { foreach (var p in Pieces.Where(p => p.IsSelected.Value)) - p.ControlPoint.Type.Value = type; + updatePathType(p, type); }); if (countOfState == totalCount) @@ -230,15 +258,5 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components return item; } - - private class PathTypeMenuItem : TernaryStateMenuItem - { - public PathTypeMenuItem(PathType? type, Action action) - : base(type == null ? "Inherit" : type.ToString().Humanize(), changeState, MenuItemType.Standard, _ => action?.Invoke()) - { - } - - private static TernaryState changeState(TernaryState state) => TernaryState.True; - } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs index 1c3d270c95..6e22c35ab3 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs @@ -26,6 +26,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { AccentColour = Color4.Transparent }; + + // SliderSelectionBlueprint relies on calling ReceivePositionalInputAt on this drawable to determine whether selection should occur. + // Without AlwaysPresent, a movement in a parent container (ie. the editor composer area resizing) could cause incorrect input handling. + AlwaysPresent = true; } [BackgroundDependencyLoader] diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs similarity index 50% rename from osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs rename to osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs index dec9cd8622..241ff70a18 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleOverlay.cs @@ -1,36 +1,32 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Graphics.Containers; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { - public class SliderCircleSelectionBlueprint : OsuSelectionBlueprint + public class SliderCircleOverlay : CompositeDrawable { protected readonly HitCirclePiece CirclePiece; + private readonly Slider slider; private readonly SliderPosition position; - public SliderCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) - : base(slider) + public SliderCircleOverlay(Slider slider, SliderPosition position) { + this.slider = slider; this.position = position; InternalChild = CirclePiece = new HitCirclePiece(); - - Select(); } protected override void Update() { base.Update(); - CirclePiece.UpdateFrom(position == SliderPosition.Start ? (HitCircle)HitObject.HeadCircle : HitObject.TailCircle); + CirclePiece.UpdateFrom(position == SliderPosition.Start ? (HitCircle)slider.HeadCircle : slider.TailCircle); } - - // Todo: This is temporary, since the slider circle masks don't do anything special yet. In the future they will handle input. - public override bool HandlePositionalInput => false; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index b71e1914f7..8b20df9a68 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private InputManager inputManager; - private PlacementState state; + private SliderPlacementState state; private PathControlPoint segmentStart; private PathControlPoint cursor; private int currentSegmentLength; @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders controlPointVisualiser = new PathControlPointVisualiser(HitObject, false) }; - setState(PlacementState.Initial); + setState(SliderPlacementState.Initial); } protected override void LoadComplete() @@ -73,12 +73,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders switch (state) { - case PlacementState.Initial: + case SliderPlacementState.Initial: BeginPlacement(); HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); break; - case PlacementState.Body: + case SliderPlacementState.Body: updateCursor(); break; } @@ -91,11 +91,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders switch (state) { - case PlacementState.Initial: + case SliderPlacementState.Initial: beginCurve(); break; - case PlacementState.Body: + case SliderPlacementState.Body: if (canPlaceNewControlPoint(out var lastPoint)) { // Place a new point by detatching the current cursor. @@ -121,7 +121,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected override void OnMouseUp(MouseUpEvent e) { - if (state == PlacementState.Body && e.Button == MouseButton.Right) + if (state == SliderPlacementState.Body && e.Button == MouseButton.Right) endCurve(); base.OnMouseUp(e); } @@ -129,19 +129,22 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void beginCurve() { BeginPlacement(commitStart: true); - setState(PlacementState.Body); + setState(SliderPlacementState.Body); } private void endCurve() { updateSlider(); - EndPlacement(true); + EndPlacement(HitObject.Path.HasValidLength); } protected override void Update() { base.Update(); updateSlider(); + + // Maintain the path type in case it got defaulted to bezier at some point during the drag. + updatePathType(); } private void updatePathType() @@ -204,7 +207,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders var lastPiece = controlPointVisualiser.Pieces.Single(p => p.ControlPoint == last); lastPoint = last; - return lastPiece?.IsHovered != true; + return lastPiece.IsHovered != true; } private void updateSlider() @@ -216,12 +219,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders tailCirclePiece.UpdateFrom(HitObject.TailCircle); } - private void setState(PlacementState newState) + private void setState(SliderPlacementState newState) { state = newState; } - private enum PlacementState + private enum SliderPlacementState { Initial, Body, diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 3d3dff653a..e810d2fe0c 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -25,12 +26,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { public class SliderSelectionBlueprint : OsuSelectionBlueprint { - protected SliderBodyPiece BodyPiece { get; private set; } - protected SliderCircleSelectionBlueprint HeadBlueprint { get; private set; } - protected SliderCircleSelectionBlueprint TailBlueprint { get; private set; } - protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } + protected new DrawableSlider DrawableObject => (DrawableSlider)base.DrawableObject; - private readonly DrawableSlider slider; + protected SliderBodyPiece BodyPiece { get; private set; } + protected SliderCircleOverlay HeadOverlay { get; private set; } + protected SliderCircleOverlay TailOverlay { get; private set; } + + [CanBeNull] + protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } [Resolved(CanBeNull = true)] private HitObjectComposer composer { get; set; } @@ -49,10 +52,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private readonly BindableList controlPoints = new BindableList(); private readonly IBindable pathVersion = new Bindable(); - public SliderSelectionBlueprint(DrawableSlider slider) + public SliderSelectionBlueprint(Slider slider) : base(slider) { - this.slider = slider; } [BackgroundDependencyLoader] @@ -61,8 +63,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders InternalChildren = new Drawable[] { BodyPiece = new SliderBodyPiece(), - HeadBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.Start), - TailBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.End), + HeadOverlay = CreateCircleOverlay(HitObject, SliderPosition.Start), + TailOverlay = CreateCircleOverlay(HitObject, SliderPosition.End), }; } @@ -100,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected override void OnSelected() { - AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(slider.HitObject, true) + AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(HitObject, true) { RemoveControlPointsRequested = removeControlPoints }); @@ -114,6 +116,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders // throw away frame buffers on deselection. ControlPointVisualiser?.Expire(); + ControlPointVisualiser = null; + BodyPiece.RecyclePath(); } @@ -210,7 +214,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // If there are 0 or 1 remaining control points, the slider is in a degenerate (single point) form and should be deleted - if (controlPoints.Count <= 1) + if (controlPoints.Count <= 1 || !HitObject.Path.HasValidLength) { placementHandler?.Delete(HitObject); return; @@ -235,11 +239,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders new OsuMenuItem("Add control point", MenuItemType.Standard, () => addControlPoint(rightClickPosition)), }; - public override Vector2 ScreenSpaceSelectionPoint => BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation); + // Always refer to the drawable object's slider body so subsequent movement deltas are calculated with updated positions. + public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathOffset) + ?? BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => BodyPiece.ReceivePositionalInputAt(screenSpacePos) || ControlPointVisualiser?.Pieces.Any(p => p.ReceivePositionalInputAt(screenSpacePos)) == true; - protected virtual SliderCircleSelectionBlueprint CreateCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) => new SliderCircleSelectionBlueprint(slider, position); + protected virtual SliderCircleOverlay CreateCircleOverlay(Slider slider, SliderPosition position) => new SliderCircleOverlay(slider, position); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs index f05d4f8435..ee573d1a01 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs @@ -3,7 +3,6 @@ using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners @@ -12,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners { private readonly SpinnerPiece piece; - public SpinnerSelectionBlueprint(DrawableSpinner spinner) + public SpinnerSelectionBlueprint(Spinner spinner) : base(spinner) { InternalChild = piece = new SpinnerPiece(); diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs new file mode 100644 index 0000000000..a342c2a821 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOffscreenObjects.cs @@ -0,0 +1,115 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit.Checks +{ + public class CheckOffscreenObjects : ICheck + { + // A close approximation for the bounding box of the screen in gameplay on 4:3 aspect ratio. + // Uses gameplay space coordinates (512 x 384 playfield / 640 x 480 screen area). + // See https://github.com/ppy/osu/pull/12361#discussion_r612199777 for reference. + private const int min_x = -67; + private const int min_y = -60; + private const int max_x = 579; + private const int max_y = 428; + + // The amount of milliseconds to step through a slider path at a time + // (higher = more performant, but higher false-negative chance). + private const int path_step_size = 5; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Compose, "Offscreen hitobjects"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateOffscreenCircle(this), + new IssueTemplateOffscreenSlider(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + foreach (var hitobject in context.Beatmap.HitObjects) + { + switch (hitobject) + { + case Slider slider: + { + foreach (var issue in sliderIssues(slider)) + yield return issue; + + break; + } + + case HitCircle circle: + { + if (isOffscreen(circle.StackedPosition, circle.Radius)) + yield return new IssueTemplateOffscreenCircle(this).Create(circle); + + break; + } + } + } + } + + /// + /// Steps through points on the slider to ensure the entire path is on-screen. + /// Returns at most one issue. + /// + /// The slider whose path to check. + /// + private IEnumerable sliderIssues(Slider slider) + { + for (int i = 0; i < slider.Distance; i += path_step_size) + { + double progress = i / slider.Distance; + Vector2 position = slider.StackedPositionAt(progress); + + if (!isOffscreen(position, slider.Radius)) + continue; + + // `SpanDuration` ensures we don't include reverses. + double time = slider.StartTime + progress * slider.SpanDuration; + yield return new IssueTemplateOffscreenSlider(this).Create(slider, time); + + yield break; + } + + // Above loop may skip the last position in the slider due to step size. + if (!isOffscreen(slider.StackedEndPosition, slider.Radius)) + yield break; + + yield return new IssueTemplateOffscreenSlider(this).Create(slider, slider.EndTime); + } + + private bool isOffscreen(Vector2 position, double radius) + { + return position.X - radius < min_x || position.X + radius > max_x || + position.Y - radius < min_y || position.Y + radius > max_y; + } + + public class IssueTemplateOffscreenCircle : IssueTemplate + { + public IssueTemplateOffscreenCircle(ICheck check) + : base(check, IssueType.Problem, "This circle goes offscreen on a 4:3 aspect ratio.") + { + } + + public Issue Create(HitCircle circle) => new Issue(circle, this); + } + + public class IssueTemplateOffscreenSlider : IssueTemplate + { + public IssueTemplateOffscreenSlider(ICheck check) + : base(check, IssueType.Problem, "This slider goes offscreen here on a 4:3 aspect ratio.") + { + } + + public Issue Create(Slider slider, double offscreenTime) => new Issue(slider, this) { Time = offscreenTime }; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs deleted file mode 100644 index 5fdb79cbbd..0000000000 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs +++ /dev/null @@ -1,79 +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.Collections.Generic; -using System.Linq; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Rulesets.Osu.UI; -using osu.Game.Rulesets.UI; -using osuTK; - -namespace osu.Game.Rulesets.Osu.Edit -{ - public class DrawableOsuEditRuleset : DrawableOsuRuleset - { - public DrawableOsuEditRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) - : base(ruleset, beatmap, mods) - { - } - - protected override Playfield CreatePlayfield() => new OsuEditPlayfield(); - - public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { Size = Vector2.One }; - - private class OsuEditPlayfield : OsuPlayfield - { - protected override GameplayCursorContainer CreateCursor() => null; - - protected override void OnNewDrawableHitObject(DrawableHitObject d) - { - d.ApplyCustomUpdateState += updateState; - } - - /// - /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay. - /// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points. - /// - private const double editor_hit_object_fade_out_extension = 700; - - private void updateState(DrawableHitObject hitObject, ArmedState state) - { - if (state == ArmedState.Idle) - return; - - // adjust the visuals of certain object types to make them stay on screen for longer than usual. - switch (hitObject) - { - default: - // there are quite a few drawable hit types we don't want to extend (spinners, ticks etc.) - return; - - case DrawableSlider _: - // no specifics to sliders but let them fade slower below. - break; - - case DrawableHitCircle circle: // also handles slider heads - circle.ApproachCircle - .FadeOutFromOne(editor_hit_object_fade_out_extension) - .Expire(); - break; - } - - // Get the existing fade out transform - var existing = hitObject.Transforms.LastOrDefault(t => t.TargetMember == nameof(Alpha)); - - if (existing == null) - return; - - hitObject.RemoveTransform(existing); - - using (hitObject.BeginAbsoluteSequence(existing.StartTime)) - hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire(); - } - } - } -} diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs new file mode 100644 index 0000000000..aeeae84d14 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Skinning.Default; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class DrawableOsuEditorRuleset : DrawableOsuRuleset + { + public DrawableOsuEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) + : base(ruleset, beatmap, mods) + { + } + + protected override Playfield CreatePlayfield() => new OsuEditorPlayfield(); + + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { Size = Vector2.One }; + + private class OsuEditorPlayfield : OsuPlayfield + { + private Bindable hitAnimations; + + protected override GameplayCursorContainer CreateCursor() => null; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + hitAnimations = config.GetBindable(OsuSetting.EditorHitAnimations); + } + + protected override void OnNewDrawableHitObject(DrawableHitObject d) + { + d.ApplyCustomUpdateState += updateState; + } + + /// + /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay. + /// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points. + /// + private const double editor_hit_object_fade_out_extension = 700; + + private void updateState(DrawableHitObject hitObject, ArmedState state) + { + if (state == ArmedState.Idle || hitAnimations.Value) + return; + + if (hitObject is DrawableHitCircle circle) + { + circle.ApproachCircle + .FadeOutFromOne(editor_hit_object_fade_out_extension * 4) + .Expire(); + + circle.ApproachCircle.ScaleTo(1.1f, 300, Easing.OutQuint); + } + + if (hitObject is IHasMainCirclePiece mainPieceContainer) + { + // clear any explode animation logic. + mainPieceContainer.CirclePiece.ApplyTransformsAt(hitObject.HitStateUpdateTime, true); + mainPieceContainer.CirclePiece.ClearTransformsAfter(hitObject.HitStateUpdateTime, true); + } + + if (hitObject is DrawableSliderRepeat repeat) + { + repeat.Arrow.ApplyTransformsAt(hitObject.HitStateUpdateTime, true); + repeat.Arrow.ClearTransformsAfter(hitObject.HitStateUpdateTime, true); + } + + // adjust the visuals of top-level object types to make them stay on screen for longer than usual. + switch (hitObject) + { + case DrawableSlider _: + case DrawableHitCircle _: + // Get the existing fade out transform + var existing = hitObject.Transforms.LastOrDefault(t => t.TargetMember == nameof(Alpha)); + + if (existing == null) + return; + + hitObject.RemoveTransform(existing); + + using (hitObject.BeginAbsoluteSequence(hitObject.HitStateUpdateTime)) + hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire(); + break; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs new file mode 100644 index 0000000000..04e881fbf3 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Osu.Edit.Checks; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class OsuBeatmapVerifier : IBeatmapVerifier + { + private readonly List checks = new List + { + new CheckOffscreenObjects() + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + return checks.SelectMany(check => check.Run(context)); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs index a68ed34e6b..dc8c3d6107 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs @@ -2,11 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners; -using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Osu.Edit @@ -18,23 +18,23 @@ namespace osu.Game.Rulesets.Osu.Edit { } - protected override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler(); + protected override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler(); - public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) { switch (hitObject) { - case DrawableHitCircle circle: + case HitCircle circle: return new HitCircleSelectionBlueprint(circle); - case DrawableSlider slider: + case Slider slider: return new SliderSelectionBlueprint(slider); - case DrawableSpinner spinner: + case Spinner spinner: return new SpinnerSelectionBlueprint(spinner); } - return base.CreateBlueprintFor(hitObject); + return base.CreateHitObjectBlueprintFor(hitObject); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 0490e8b8ce..806b7e6051 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Edit } protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) - => new DrawableOsuEditRuleset(ruleset, beatmap, mods); + => new DrawableOsuEditorRuleset(ruleset, beatmap, mods); protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { @@ -82,6 +82,9 @@ namespace osu.Game.Rulesets.Osu.Edit protected override ComposeBlueprintContainer CreateBlueprintContainer() => new OsuBlueprintContainer(this); + public override string ConvertSelectionToString() + => string.Join(',', selectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => (h.IndexInCurrentCombo + 1).ToString())); + private DistanceSnapGrid distanceSnapGrid; private Container distanceSnapGridContainer; @@ -144,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Edit if (b.IsSelected) continue; - var hitObject = (OsuHitObject)b.HitObject; + var hitObject = (OsuHitObject)b.Item; Vector2? snap = checkSnap(hitObject.Position); if (snap == null && hitObject.Position != hitObject.EndPosition) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 871339ae7b..57d0cd859d 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -1,12 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; +using osu.Game.Extensions; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; @@ -15,8 +15,19 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit { - public class OsuSelectionHandler : SelectionHandler + public class OsuSelectionHandler : EditorSelectionHandler { + /// + /// During a transform, the initial origin is stored so it can be used throughout the operation. + /// + private Vector2? referenceOrigin; + + /// + /// During a transform, the initial path types of a single selected slider are stored so they + /// can be maintained throughout the operation. + /// + private List referencePathTypes; + protected override void OnSelectionChanged() { base.OnSelectionChanged(); @@ -33,15 +44,21 @@ namespace osu.Game.Rulesets.Osu.Edit { base.OnOperationEnded(); referenceOrigin = null; + referencePathTypes = null; } - public override bool HandleMovement(MoveSelectionEvent moveEvent) => - moveSelection(moveEvent.InstantDelta); + public override bool HandleMovement(MoveSelectionEvent moveEvent) + { + var hitObjects = selectedMovableObjects; - /// - /// During a transform, the initial origin is stored so it can be used throughout the operation. - /// - private Vector2? referenceOrigin; + // this will potentially move the selection out of bounds... + foreach (var h in hitObjects) + h.Position += this.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); + + // but this will be corrected. + moveSelectionInBounds(); + return true; + } public override bool HandleReverse() { @@ -96,24 +113,10 @@ namespace osu.Game.Rulesets.Osu.Edit var hitObjects = selectedMovableObjects; var selectedObjectsQuad = getSurroundingQuad(hitObjects); - var centre = selectedObjectsQuad.Centre; foreach (var h in hitObjects) { - var pos = h.Position; - - switch (direction) - { - case Direction.Horizontal: - pos.X = centre.X - (pos.X - centre.X); - break; - - case Direction.Vertical: - pos.Y = centre.Y - (pos.Y - centre.Y); - break; - } - - h.Position = pos; + h.Position = GetFlippedPosition(direction, selectedObjectsQuad, h.Position); if (h is Slider slider) { @@ -140,36 +143,11 @@ namespace osu.Game.Rulesets.Osu.Edit // the only hit object selected. with a group selection, it's likely the user // is not looking to change the duration of the slider but expand the whole pattern. if (hitObjects.Length == 1 && hitObjects.First() is Slider slider) - { - Quad quad = getSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position.Value)); - Vector2 pathRelativeDeltaScale = new Vector2(1 + scale.X / quad.Width, 1 + scale.Y / quad.Height); - - foreach (var point in slider.Path.ControlPoints) - point.Position.Value *= pathRelativeDeltaScale; - } + scaleSlider(slider, scale); else - { - // move the selection before scaling if dragging from top or left anchors. - if ((reference & Anchor.x0) > 0 && !moveSelection(new Vector2(-scale.X, 0))) return false; - if ((reference & Anchor.y0) > 0 && !moveSelection(new Vector2(0, -scale.Y))) return false; - - Quad quad = getSurroundingQuad(hitObjects); - - foreach (var h in hitObjects) - { - var newPosition = h.Position; - - // guard against no-ops and NaN. - if (scale.X != 0 && quad.Width > 0) - newPosition.X = quad.TopLeft.X + (h.X - quad.TopLeft.X) / quad.Width * (quad.Width + scale.X); - - if (scale.Y != 0 && quad.Height > 0) - newPosition.Y = quad.TopLeft.Y + (h.Y - quad.TopLeft.Y) / quad.Height * (quad.Height + scale.Y); - - h.Position = newPosition; - } - } + scaleHitObjects(hitObjects, reference, scale); + moveSelectionInBounds(); return true; } @@ -194,12 +172,12 @@ namespace osu.Game.Rulesets.Osu.Edit foreach (var h in hitObjects) { - h.Position = rotatePointAroundOrigin(h.Position, referenceOrigin.Value, delta); + h.Position = RotatePointAroundOrigin(h.Position, referenceOrigin.Value, delta); if (h is IHasPath path) { foreach (var point in path.Path.ControlPoints) - point.Position.Value = rotatePointAroundOrigin(point.Position.Value, Vector2.Zero, delta); + point.Position.Value = RotatePointAroundOrigin(point.Position.Value, Vector2.Zero, delta); } } @@ -207,28 +185,116 @@ namespace osu.Game.Rulesets.Osu.Edit return true; } - private bool moveSelection(Vector2 delta) + private void scaleSlider(Slider slider, Vector2 scale) + { + referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type.Value).ToList(); + + Quad sliderQuad = GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position.Value)); + + // Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0. + scale = Vector2.ComponentMax(new Vector2(Precision.FLOAT_EPSILON), sliderQuad.Size + scale) - sliderQuad.Size; + + Vector2 pathRelativeDeltaScale = new Vector2( + sliderQuad.Width == 0 ? 0 : 1 + scale.X / sliderQuad.Width, + sliderQuad.Height == 0 ? 0 : 1 + scale.Y / sliderQuad.Height); + + Queue oldControlPoints = new Queue(); + + foreach (var point in slider.Path.ControlPoints) + { + oldControlPoints.Enqueue(point.Position.Value); + point.Position.Value *= pathRelativeDeltaScale; + } + + // Maintain the path types in case they were defaulted to bezier at some point during scaling + for (int i = 0; i < slider.Path.ControlPoints.Count; ++i) + slider.Path.ControlPoints[i].Type.Value = referencePathTypes[i]; + + //if sliderhead or sliderend end up outside playfield, revert scaling. + Quad scaledQuad = getSurroundingQuad(new OsuHitObject[] { slider }); + (bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad); + + if (xInBounds && yInBounds && slider.Path.HasValidLength) + return; + + foreach (var point in slider.Path.ControlPoints) + point.Position.Value = oldControlPoints.Dequeue(); + } + + private void scaleHitObjects(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale) + { + scale = getClampedScale(hitObjects, reference, scale); + Quad selectionQuad = getSurroundingQuad(hitObjects); + + foreach (var h in hitObjects) + h.Position = GetScaledPosition(reference, scale, selectionQuad, h.Position); + } + + private (bool X, bool Y) isQuadInBounds(Quad quad) + { + bool xInBounds = (quad.TopLeft.X >= 0) && (quad.BottomRight.X <= DrawWidth); + bool yInBounds = (quad.TopLeft.Y >= 0) && (quad.BottomRight.Y <= DrawHeight); + + return (xInBounds, yInBounds); + } + + private void moveSelectionInBounds() { var hitObjects = selectedMovableObjects; Quad quad = getSurroundingQuad(hitObjects); - Vector2 newTopLeft = quad.TopLeft + delta; - if (newTopLeft.X < 0) - delta.X -= newTopLeft.X; - if (newTopLeft.Y < 0) - delta.Y -= newTopLeft.Y; + Vector2 delta = Vector2.Zero; - Vector2 newBottomRight = quad.BottomRight + delta; - if (newBottomRight.X > DrawWidth) - delta.X -= newBottomRight.X - DrawWidth; - if (newBottomRight.Y > DrawHeight) - delta.Y -= newBottomRight.Y - DrawHeight; + if (quad.TopLeft.X < 0) + delta.X -= quad.TopLeft.X; + if (quad.TopLeft.Y < 0) + delta.Y -= quad.TopLeft.Y; + + if (quad.BottomRight.X > DrawWidth) + delta.X -= quad.BottomRight.X - DrawWidth; + if (quad.BottomRight.Y > DrawHeight) + delta.Y -= quad.BottomRight.Y - DrawHeight; foreach (var h in hitObjects) h.Position += delta; + } - return true; + /// + /// Clamp scale for multi-object-scaling where selection does not exceed playfield bounds or flip. + /// + /// The hitobjects to be scaled + /// The anchor from which the scale operation is performed + /// The scale to be clamped + /// The clamped scale vector + private Vector2 getClampedScale(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale) + { + float xOffset = ((reference & Anchor.x0) > 0) ? -scale.X : 0; + float yOffset = ((reference & Anchor.y0) > 0) ? -scale.Y : 0; + + Quad selectionQuad = getSurroundingQuad(hitObjects); + + //todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead. + Quad scaledQuad = new Quad(selectionQuad.TopLeft.X + xOffset, selectionQuad.TopLeft.Y + yOffset, selectionQuad.Width + scale.X, selectionQuad.Height + scale.Y); + + //max Size -> playfield bounds + if (scaledQuad.TopLeft.X < 0) + scale.X += scaledQuad.TopLeft.X; + if (scaledQuad.TopLeft.Y < 0) + scale.Y += scaledQuad.TopLeft.Y; + + if (scaledQuad.BottomRight.X > DrawWidth) + scale.X -= scaledQuad.BottomRight.X - DrawWidth; + if (scaledQuad.BottomRight.Y > DrawHeight) + scale.Y -= scaledQuad.BottomRight.Y - DrawHeight; + + //min Size -> almost 0. Less than 0 causes the quad to flip, exactly 0 causes scaling to get stuck at minimum scale. + Vector2 scaledSize = selectionQuad.Size + scale; + Vector2 minSize = new Vector2(Precision.FLOAT_EPSILON); + + scale = Vector2.ComponentMax(minSize, scaledSize) - selectionQuad.Size; + + return scale; } /// @@ -236,7 +302,7 @@ namespace osu.Game.Rulesets.Osu.Edit /// /// The hit objects to calculate a quad for. private Quad getSurroundingQuad(OsuHitObject[] hitObjects) => - getSurroundingQuad(hitObjects.SelectMany(h => + GetSurroundingQuad(hitObjects.SelectMany(h => { if (h is IHasPath path) { @@ -251,59 +317,11 @@ namespace osu.Game.Rulesets.Osu.Edit return new[] { h.Position }; })); - /// - /// Returns a gamefield-space quad surrounding the provided points. - /// - /// The points to calculate a quad for. - private Quad getSurroundingQuad(IEnumerable points) - { - if (!EditorBeatmap.SelectedHitObjects.Any()) - return new Quad(); - - Vector2 minPosition = new Vector2(float.MaxValue, float.MaxValue); - Vector2 maxPosition = new Vector2(float.MinValue, float.MinValue); - - // Go through all hitobjects to make sure they would remain in the bounds of the editor after movement, before any movement is attempted - foreach (var p in points) - { - minPosition = Vector2.ComponentMin(minPosition, p); - maxPosition = Vector2.ComponentMax(maxPosition, p); - } - - Vector2 size = maxPosition - minPosition; - - return new Quad(minPosition.X, minPosition.Y, size.X, size.Y); - } - /// /// All osu! hitobjects which can be moved/rotated/scaled. /// - private OsuHitObject[] selectedMovableObjects => EditorBeatmap.SelectedHitObjects - .OfType() + private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType() .Where(h => !(h is Spinner)) .ToArray(); - - /// - /// Rotate a point around an arbitrary origin. - /// - /// The point. - /// The centre origin to rotate around. - /// The angle to rotate (in degrees). - private static Vector2 rotatePointAroundOrigin(Vector2 point, Vector2 origin, float angle) - { - angle = -angle; - - point.X -= origin.X; - point.Y -= origin.Y; - - Vector2 ret; - ret.X = point.X * MathF.Cos(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Sin(MathUtils.DegreesToRadians(angle)); - ret.Y = point.X * -MathF.Sin(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Cos(MathUtils.DegreesToRadians(angle)); - - ret.X += origin.X; - ret.Y += origin.Y; - - return ret; - } } } diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs index e58aacd86e..9f77175398 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuSpinnerJudgementResult.cs @@ -38,6 +38,11 @@ namespace osu.Game.Rulesets.Osu.Judgements /// public float RateAdjustedRotation; + /// + /// Time instant at which the spin was started (the first user input which caused an increase in spin). + /// + public double? TimeStarted; + /// /// Time instant at which the spinner has been completed (the user has executed all required spins). /// Will be null if all required spins haven't been completed. diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 77de0cb45b..aac830801b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Automation; public override string Description => @"Automatic cursor movement - just follow the rhythm."; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModNoFail), typeof(ModAutoplay) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay) }; public bool PerformFail() => false; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs new file mode 100644 index 0000000000..9ae9653e9b --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.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.Collections.Generic; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModBarrelRoll : ModBarrelRoll, IApplicableToDrawableHitObjects + { + public void ApplyToDrawableHitObjects(IEnumerable drawables) + { + foreach (var d in drawables) + { + d.OnUpdate += _ => + { + switch (d) + { + case DrawableHitCircle circle: + circle.CirclePiece.Rotation = -CurrentRotation; + break; + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs index 5470d0fcb4..77dea5b0dc 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -16,22 +15,8 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModClassic : Mod, IApplicableToHitObject, IApplicableToDrawableHitObjects, IApplicableToDrawableRuleset + public class OsuModClassic : ModClassic, IApplicableToHitObject, IApplicableToDrawableHitObjects, IApplicableToDrawableRuleset { - public override string Name => "Classic"; - - public override string Acronym => "CL"; - - public override double ScoreMultiplier => 1; - - public override IconUsage? Icon => FontAwesome.Solid.History; - - public override string Description => "Feeling nostalgic?"; - - public override bool Ranked => false; - - public override ModType Type => ModType.Conversion; - [SettingSource("No slider head accuracy requirement", "Scores sliders proportionally to the number of ticks hit.")] public Bindable NoSliderHeadAccuracy { get; } = new BindableBool(true); @@ -44,6 +29,9 @@ namespace osu.Game.Rulesets.Osu.Mods [SettingSource("Use fixed slider follow circle hit area", "Makes the slider follow circle track its final size at all times.")] public Bindable FixedFollowCircleHitArea { get; } = new BindableBool(true); + [SettingSource("Always play a slider's tail sample", "Always plays a slider's tail sample regardless of whether it was hit or not.")] + public Bindable AlwaysPlayTailSample { get; } = new BindableBool(true); + public void ApplyToHitObject(HitObject hitObject) { switch (hitObject) @@ -79,6 +67,10 @@ namespace osu.Game.Rulesets.Osu.Mods case DrawableSliderHead head: head.TrackFollowCircle = !NoSliderHeadMovement.Value; break; + + case DrawableSliderTail tail: + tail.SamplePlaysOnlyOnHit = !AlwaysPlayTailSample.Value; + break; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 20c0818d03..683b35f282 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs @@ -9,10 +9,12 @@ using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.UI; using osuTK; namespace osu.Game.Rulesets.Osu.Mods @@ -23,6 +25,8 @@ namespace osu.Game.Rulesets.Osu.Mods private const float default_flashlight_size = 180; + private const double default_follow_delay = 120; + private OsuFlashlight flashlight; public override Flashlight CreateFlashlight() => flashlight = new OsuFlashlight(); @@ -35,8 +39,25 @@ namespace osu.Game.Rulesets.Osu.Mods } } + public override void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + base.ApplyToDrawableRuleset(drawableRuleset); + + flashlight.FollowDelay = FollowDelay.Value; + } + + [SettingSource("Follow delay", "Milliseconds until the flashlight reaches the cursor")] + public BindableNumber FollowDelay { get; } = new BindableDouble(default_follow_delay) + { + MinValue = default_follow_delay, + MaxValue = default_follow_delay * 10, + Precision = default_follow_delay, + }; + private class OsuFlashlight : Flashlight, IRequireHighFrequencyMousePosition { + public double FollowDelay { private get; set; } + public OsuFlashlight() { FlashlightSize = new Vector2(0, getSizeFor(0)); @@ -50,13 +71,11 @@ namespace osu.Game.Rulesets.Osu.Mods protected override bool OnMouseMove(MouseMoveEvent e) { - const double follow_delay = 120; - var position = FlashlightPosition; var destination = e.MousePosition; FlashlightPosition = Interpolation.ValueAt( - Math.Min(Math.Abs(Clock.ElapsedFrameTime), follow_delay), position, destination, 0, follow_delay, Easing.Out); + Math.Min(Math.Abs(Clock.ElapsedFrameTime), FollowDelay), position, destination, 0, FollowDelay, Easing.Out); return base.OnMouseMove(e); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 45f314af7b..2752feb0a1 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -43,13 +43,11 @@ namespace osu.Game.Rulesets.Osu.Mods protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) { - base.ApplyIncreasedVisibilityState(hitObject, state); applyState(hitObject, true); } protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) { - base.ApplyNormalVisibilityState(hitObject, state); applyState(hitObject, false); } @@ -60,20 +58,20 @@ namespace osu.Game.Rulesets.Osu.Mods OsuHitObject hitObject = drawableOsuObject.HitObject; - (double startTime, double duration) fadeOut = getFadeOutParameters(drawableOsuObject); + (double fadeStartTime, double fadeDuration) = getFadeOutParameters(drawableOsuObject); switch (drawableObject) { case DrawableSliderTail _: - using (drawableObject.BeginAbsoluteSequence(fadeOut.startTime, true)) - drawableObject.FadeOut(fadeOut.duration); + using (drawableObject.BeginAbsoluteSequence(fadeStartTime)) + drawableObject.FadeOut(fadeDuration); break; case DrawableSliderRepeat sliderRepeat: - using (drawableObject.BeginAbsoluteSequence(fadeOut.startTime, true)) + using (drawableObject.BeginAbsoluteSequence(fadeStartTime)) // only apply to circle piece – reverse arrow is not affected by hidden. - sliderRepeat.CirclePiece.FadeOut(fadeOut.duration); + sliderRepeat.CirclePiece.FadeOut(fadeDuration); break; @@ -88,23 +86,23 @@ namespace osu.Game.Rulesets.Osu.Mods else { // we don't want to see the approach circle - using (circle.BeginAbsoluteSequence(hitObject.StartTime - hitObject.TimePreempt, true)) + using (circle.BeginAbsoluteSequence(hitObject.StartTime - hitObject.TimePreempt)) circle.ApproachCircle.Hide(); } - using (drawableObject.BeginAbsoluteSequence(fadeOut.startTime, true)) - fadeTarget.FadeOut(fadeOut.duration); + using (drawableObject.BeginAbsoluteSequence(fadeStartTime)) + fadeTarget.FadeOut(fadeDuration); break; case DrawableSlider slider: - using (slider.BeginAbsoluteSequence(fadeOut.startTime, true)) - slider.Body.FadeOut(fadeOut.duration, Easing.Out); + using (slider.BeginAbsoluteSequence(fadeStartTime)) + slider.Body.FadeOut(fadeDuration, Easing.Out); break; case DrawableSliderTick sliderTick: - using (sliderTick.BeginAbsoluteSequence(fadeOut.startTime, true)) - sliderTick.FadeOut(fadeOut.duration); + using (sliderTick.BeginAbsoluteSequence(fadeStartTime)) + sliderTick.FadeOut(fadeDuration); break; @@ -112,14 +110,14 @@ namespace osu.Game.Rulesets.Osu.Mods // hide elements we don't care about. // todo: hide background - using (spinner.BeginAbsoluteSequence(fadeOut.startTime, true)) - spinner.FadeOut(fadeOut.duration); + using (spinner.BeginAbsoluteSequence(fadeStartTime)) + spinner.FadeOut(fadeDuration); break; } } - private (double startTime, double duration) getFadeOutParameters(DrawableOsuHitObject drawableObject) + private (double fadeStartTime, double fadeDuration) getFadeOutParameters(DrawableOsuHitObject drawableObject) { switch (drawableObject) { @@ -137,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Mods return getParameters(drawableObject.HitObject); } - static (double startTime, double duration) getParameters(OsuHitObject hitObject) + static (double fadeStartTime, double fadeDuration) getParameters(OsuHitObject hitObject) { var fadeOutStartTime = hitObject.StartTime - hitObject.TimePreempt + hitObject.TimeFadeIn; var fadeOutDuration = hitObject.TimePreempt * fade_out_duration_multiplier; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs index f0db548e74..3b16e9d2b7 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs @@ -9,6 +9,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Touch Device"; public override string Acronym => "TD"; + public override string Description => "Automatically applied to plays on devices with a touchscreen."; public override double ScoreMultiplier => 1; public override ModType Type => ModType.System; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index df0a41455f..4b0939db16 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -11,7 +11,7 @@ using osu.Game.Rulesets.Osu.Skinning.Default; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModTraceable : ModWithVisibilityAdjustment + public class OsuModTraceable : ModWithVisibilityAdjustment { public override string Name => "Traceable"; public override string Acronym => "TC"; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index 9c5e41f245..a01cec4bb3 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -34,9 +34,9 @@ namespace osu.Game.Rulesets.Osu.Mods var osuObject = (OsuHitObject)drawable.HitObject; Vector2 origin = drawable.Position; - // Wiggle the repeat points with the slider instead of independently. + // Wiggle the repeat points and the tail with the slider instead of independently. // Also fixes an issue with repeat points being positioned incorrectly. - if (osuObject is SliderRepeat) + if (osuObject is SliderRepeat || osuObject is SliderTailCircle) return; Random objRand = new Random((int)osuObject.StartTime); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index 5541d0e790..cda4715280 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections Entry = null; } - private void onEntryInvalidated() => refreshPoints(); + private void onEntryInvalidated() => Scheduler.AddOnce(refreshPoints); private void refreshPoints() { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 77094f928b..236af4b3f1 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -19,7 +19,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableHitCircle : DrawableOsuHitObject + public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece { public OsuAction? HitAction => HitArea.HitAction; protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; @@ -66,7 +66,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return true; }, }, - CirclePiece = new SkinnableDrawable(new OsuSkinComponent(CirclePieceComponent), _ => new MainCirclePiece()), + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(CirclePieceComponent), _ => new MainCirclePiece()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, ApproachCircle = new ApproachCircle { Alpha = 0, @@ -164,28 +168,29 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ApproachCircle.Expire(true); } + protected override void UpdateStartTimeStateTransforms() + { + base.UpdateStartTimeStateTransforms(); + + ApproachCircle.FadeOut(50); + } + protected override void UpdateHitStateTransforms(ArmedState state) { Debug.Assert(HitObject.HitWindows != null); + // todo: temporary / arbitrary, used for lifetime optimisation. + this.Delay(800).FadeOut(); + switch (state) { case ArmedState.Idle: - this.Delay(HitObject.TimePreempt).FadeOut(500); HitArea.HitAction = null; break; case ArmedState.Miss: - ApproachCircle.FadeOut(50); this.FadeOut(100); break; - - case ArmedState.Hit: - ApproachCircle.FadeOut(50); - - // todo: temporary / arbitrary - this.Delay(800).FadeOut(); - break; } Expire(); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 9122f347d0..0bec33bf77 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.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.Linq; using JetBrains.Annotations; using osuTK; @@ -33,7 +34,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public override bool DisplayResult => !HitObject.OnlyJudgeNestedObjects; - private PlaySliderBody sliderBody => Body.Drawable as PlaySliderBody; + [CanBeNull] + public PlaySliderBody SliderBody => Body.Drawable as PlaySliderBody; public IBindable PathVersion => pathVersion; private readonly Bindable pathVersion = new Bindable(); @@ -108,16 +110,27 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void LoadSamples() { - base.LoadSamples(); + // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being. - var firstSample = HitObject.Samples.FirstOrDefault(); - - if (firstSample != null) + if (HitObject.SampleControlPoint == null) { - var clone = HitObject.SampleControlPoint.ApplyTo(firstSample).With("sliderslide"); - - slidingSample.Samples = new ISampleInfo[] { clone }; + throw new InvalidOperationException($"{nameof(HitObject)}s must always have an attached {nameof(HitObject.SampleControlPoint)}." + + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); } + + Samples.Samples = HitObject.TailSamples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); + + var slidingSamples = new List(); + + var normalSample = HitObject.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); + if (normalSample != null) + slidingSamples.Add(HitObject.SampleControlPoint.ApplyTo(normalSample).With("sliderslide")); + + var whistleSample = HitObject.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE); + if (whistleSample != null) + slidingSamples.Add(HitObject.SampleControlPoint.ApplyTo(whistleSample).With("sliderwhistle")); + + slidingSample.Samples = slidingSamples.ToArray(); } public override void StopAllSamples() @@ -203,16 +216,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables double completionProgress = Math.Clamp((Time.Current - HitObject.StartTime) / HitObject.Duration, 0, 1); Ball.UpdateProgress(completionProgress); - sliderBody?.UpdateProgress(completionProgress); + SliderBody?.UpdateProgress(completionProgress); foreach (DrawableHitObject hitObject in NestedHitObjects) { - if (hitObject is ITrackSnaking s) s.UpdateSnakingPosition(HitObject.Path.PositionAt(sliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(sliderBody?.SnakedEnd ?? 0)); + if (hitObject is ITrackSnaking s) s.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0)); if (hitObject is IRequireTracking t) t.Tracking = Ball.Tracking; } - Size = sliderBody?.Size ?? Vector2.Zero; - OriginPosition = sliderBody?.PathOffset ?? Vector2.Zero; + Size = SliderBody?.Size ?? Vector2.Zero; + OriginPosition = SliderBody?.PathOffset ?? Vector2.Zero; if (DrawSize != Vector2.Zero) { @@ -226,7 +239,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public override void OnKilled() { base.OnKilled(); - sliderBody?.RecyclePath(); + SliderBody?.RecyclePath(); } protected override void ApplySkin(ISkinSource skin, bool allowFallback) @@ -280,7 +293,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { // rather than doing it this way, we should probably attach the sample to the tail circle. // this can only be done after we stop using LegacyLastTick. - if (TailCircle.IsHit) + if (!TailCircle.SamplePlaysOnlyOnHit || TailCircle.IsHit) base.PlaySamples(); } @@ -312,7 +325,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { case ArmedState.Hit: Ball.ScaleTo(HitObject.Scale * 1.4f, fade_out_time, Easing.Out); - if (sliderBody?.SnakingOut.Value == true) + if (SliderBody?.SnakingOut.Value == true) Body.FadeOut(40); // short fade to allow for any body colour to smoothly disappear. break; } @@ -320,7 +333,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables this.FadeOut(fade_out_time, Easing.OutQuint).Expire(); } - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => sliderBody?.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos); + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => SliderBody?.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos); private class DefaultSliderBody : PlaySliderBody { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 76490e0de1..b7458b5695 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -15,7 +15,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking + public class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking, IHasMainCirclePiece { public new SliderRepeat HitObject => (SliderRepeat)base.HitObject; @@ -26,9 +26,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables private double animDuration; - public Drawable CirclePiece { get; private set; } + public SkinnableDrawable CirclePiece { get; private set; } + + public ReverseArrowPiece Arrow { get; private set; } + private Drawable scaleContainer; - private ReverseArrowPiece arrow; public override bool DisplayResult => false; @@ -53,11 +55,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Children = new[] + Children = new Drawable[] { // no default for this; only visible in legacy skins. - CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()), - arrow = new ReverseArrowPiece(), + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + Arrow = new ReverseArrowPiece(), } }; @@ -102,8 +108,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables break; case ArmedState.Hit: - this.FadeOut(animDuration, Easing.Out) - .ScaleTo(Scale * 1.5f, animDuration, Easing.Out); + this.FadeOut(animDuration, Easing.Out); + + const float final_scale = 1.5f; + + Arrow.ScaleTo(Scale * final_scale, animDuration, Easing.Out); + CirclePiece.ScaleTo(Scale * final_scale, animDuration, Easing.Out); break; } } @@ -139,18 +149,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables } float aimRotation = MathUtils.RadiansToDegrees(MathF.Atan2(aimRotationVector.Y - Position.Y, aimRotationVector.X - Position.X)); - while (Math.Abs(aimRotation - arrow.Rotation) > 180) - aimRotation += aimRotation < arrow.Rotation ? 360 : -360; + while (Math.Abs(aimRotation - Arrow.Rotation) > 180) + aimRotation += aimRotation < Arrow.Rotation ? 360 : -360; if (!hasRotation) { - arrow.Rotation = aimRotation; + Arrow.Rotation = aimRotation; hasRotation = true; } else { // If we're already snaking, interpolate to smooth out sharp curves (linear sliders, mainly). - arrow.Rotation = Interpolation.ValueAt(Math.Clamp(Clock.ElapsedFrameTime, 0, 100), arrow.Rotation, aimRotation, 0, 50, Easing.OutQuint); + Arrow.Rotation = Interpolation.ValueAt(Math.Clamp(Clock.ElapsedFrameTime, 0, 100), Arrow.Rotation, aimRotation, 0, 50, Easing.OutQuint); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 6a8e02e886..ec1387eb54 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -7,12 +7,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, ITrackSnaking + public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, IHasMainCirclePiece { public new SliderTailCircle HitObject => (SliderTailCircle)base.HitObject; @@ -26,9 +28,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables /// public override bool DisplayResult => false; + /// + /// Whether the hit samples only play on successful hits. + /// If false, the hit samples will also play on misses. + /// + public bool SamplePlaysOnlyOnHit { get; set; } = true; + public bool Tracking { get; set; } - private SkinnableDrawable circlePiece; + public SkinnableDrawable CirclePiece { get; private set; } + private Container scaleContainer; public DrawableSliderTail() @@ -57,7 +66,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Children = new Drawable[] { // no default for this; only visible in legacy skins. - circlePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) } }, }; @@ -69,7 +78,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateInitialTransforms(); - circlePiece.FadeInFromZero(HitObject.TimeFadeIn); + CirclePiece.FadeInFromZero(HitObject.TimeFadeIn); } protected override void UpdateHitStateTransforms(ArmedState state) @@ -101,7 +110,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); } - public void UpdateSnakingPosition(Vector2 start, Vector2 end) => - Position = HitObject.RepeatIndex % 2 == 0 ? end : start; + protected override void OnApply() + { + base.OnApply(); + + if (Slider != null) + Position = Slider.CurvePositionAt(HitObject.RepeatIndex % 2 == 0 ? 1 : 0); + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index d02376b6c3..19cee61f26 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -30,15 +30,32 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public new OsuSpinnerJudgementResult Result => (OsuSpinnerJudgementResult)base.Result; public SpinnerRotationTracker RotationTracker { get; private set; } - public SpinnerSpmCounter SpmCounter { get; private set; } + + private SpinnerSpmCalculator spmCalculator; private Container ticks; - private SpinnerBonusDisplay bonusDisplay; private PausableSkinnableSound spinningSample; private Bindable isSpinning; private bool spinnerFrequencyModulate; + private const float spinning_sample_initial_frequency = 1.0f; + private const float spinning_sample_modulated_base_frequency = 0.5f; + + /// + /// The amount of bonus score gained from spinning after the required number of spins, for display purposes. + /// + public IBindable GainedBonus => gainedBonus; + + private readonly Bindable gainedBonus = new BindableDouble(); + + /// + /// The number of spins per minute this spinner is spinning at, for display purposes. + /// + public readonly IBindable SpinsPerMinute = new BindableDouble(); + + private const double fade_out_duration = 160; + public DrawableSpinner() : this(null) { @@ -55,8 +72,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; - InternalChildren = new Drawable[] + AddRangeInternal(new Drawable[] { + spmCalculator = new SpinnerSpmCalculator + { + Result = { BindTarget = SpinsPerMinute }, + }, ticks = new Container(), new AspectContainer { @@ -65,30 +86,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables RelativeSizeAxes = Axes.Y, Children = new Drawable[] { - new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinnerDisc()), + new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinner()), RotationTracker = new SpinnerRotationTracker(this) } }, - SpmCounter = new SpinnerSpmCounter - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Y = 120, - Alpha = 0 - }, - bonusDisplay = new SpinnerBonusDisplay - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Y = -120, - }, spinningSample = new PausableSkinnableSound { Volume = { Value = 0 }, Looping = true, Frequency = { Value = spinning_sample_initial_frequency } } - }; + }); PositionBindable.BindValueChanged(pos => Position = pos.NewValue); } @@ -101,9 +109,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables isSpinning.BindValueChanged(updateSpinningSample); } - private const float spinning_sample_initial_frequency = 1.0f; - private const float spinning_sample_modulated_base_frequency = 0.5f; - protected override void OnFree() { base.OnFree(); @@ -131,12 +136,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (tracking.NewValue) { if (!spinningSample.IsPlaying) - spinningSample?.Play(); - spinningSample?.VolumeTo(1, 300); + spinningSample.Play(); + + spinningSample.VolumeTo(1, 300); } else { - spinningSample?.VolumeTo(0, 300).OnComplete(_ => spinningSample.Stop()); + spinningSample.VolumeTo(0, fade_out_duration); } } @@ -162,7 +168,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateHitStateTransforms(state); - this.FadeOut(160).Expire(); + this.FadeOut(fade_out_duration).OnComplete(_ => + { + // looping sample should be stopped here as it is safer than running in the OnComplete + // of the volume transition above. + spinningSample.Stop(); + }); + + Expire(); // skin change does a rewind of transforms, which will stop the spinning sound from playing if it's currently in playback. isSpinning?.TriggerChange(); @@ -261,14 +274,19 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateAfterChildren(); - if (!SpmCounter.IsPresent && RotationTracker.Tracking) - SpmCounter.FadeIn(HitObject.TimeFadeIn); + if (Result.TimeStarted == null && RotationTracker.Tracking) + Result.TimeStarted = Time.Current; - SpmCounter.SetRotation(Result.RateAdjustedRotation); + // don't update after end time to avoid the rate display dropping during fade out. + // this shouldn't be limited to StartTime as it causes weirdness with the underlying calculation, which is expecting updates during that period. + if (Time.Current <= HitObject.EndTime) + spmCalculator.SetRotation(Result.RateAdjustedRotation); updateBonusScore(); } + private static readonly int score_per_tick = new SpinnerBonusTick.OsuSpinnerBonusTickJudgement().MaxNumericResult; + private int wholeSpins; private void updateBonusScore() @@ -293,8 +311,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables if (tick != null) { tick.TriggerResult(true); + if (tick is DrawableSpinnerBonusTick) - bonusDisplay.SetBonusCount(spins - HitObject.SpinsRequired); + gainedBonus.Value = score_per_tick * (spins - HitObject.SpinsRequired); } wholeSpins++; diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index e2b6c84896..8ba9597dc3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -81,6 +81,9 @@ namespace osu.Game.Rulesets.Osu.Objects public List> NodeSamples { get; set; } = new List>(); + [JsonIgnore] + public IList TailSamples { get; private set; } + private int repeatCount; public int RepeatCount @@ -143,11 +146,6 @@ namespace osu.Game.Rulesets.Osu.Objects Velocity = scoringDistance / timingPoint.BeatLength; TickDistance = scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier; - - // The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to. - // For now, the samples are attached to and played by the slider itself at the correct end time. - // ToArray call is required as GetNodeSamples may fallback to Samples itself (without it it will get cleared due to the list reference being live). - Samples = this.GetNodeSamples(repeatCount + 1).ToArray(); } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) @@ -238,6 +236,10 @@ namespace osu.Game.Rulesets.Osu.Objects if (HeadCircle != null) HeadCircle.Samples = this.GetNodeSamples(0); + + // The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to. + // For now, the samples are played by the slider itself at the correct end time. + TailSamples = this.GetNodeSamples(repeatCount + 1); } public override Judgement CreateJudgement() => OnlyJudgeNestedObjects ? new OsuIgnoreJudgement() : new OsuJudgement(); diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 18324a18a8..465d6d7155 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -29,6 +29,7 @@ using osu.Game.Scoring; using osu.Game.Skinning; using System; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Skinning.Legacy; using osu.Game.Rulesets.Osu.Statistics; @@ -58,52 +59,52 @@ namespace osu.Game.Rulesets.Osu public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlag(LegacyMods.Nightcore)) + if (mods.HasFlagFast(LegacyMods.Nightcore)) yield return new OsuModNightcore(); - else if (mods.HasFlag(LegacyMods.DoubleTime)) + else if (mods.HasFlagFast(LegacyMods.DoubleTime)) yield return new OsuModDoubleTime(); - if (mods.HasFlag(LegacyMods.Perfect)) + if (mods.HasFlagFast(LegacyMods.Perfect)) yield return new OsuModPerfect(); - else if (mods.HasFlag(LegacyMods.SuddenDeath)) + else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) yield return new OsuModSuddenDeath(); - if (mods.HasFlag(LegacyMods.Autopilot)) + if (mods.HasFlagFast(LegacyMods.Autopilot)) yield return new OsuModAutopilot(); - if (mods.HasFlag(LegacyMods.Cinema)) + if (mods.HasFlagFast(LegacyMods.Cinema)) yield return new OsuModCinema(); - else if (mods.HasFlag(LegacyMods.Autoplay)) + else if (mods.HasFlagFast(LegacyMods.Autoplay)) yield return new OsuModAutoplay(); - if (mods.HasFlag(LegacyMods.Easy)) + if (mods.HasFlagFast(LegacyMods.Easy)) yield return new OsuModEasy(); - if (mods.HasFlag(LegacyMods.Flashlight)) + if (mods.HasFlagFast(LegacyMods.Flashlight)) yield return new OsuModFlashlight(); - if (mods.HasFlag(LegacyMods.HalfTime)) + if (mods.HasFlagFast(LegacyMods.HalfTime)) yield return new OsuModHalfTime(); - if (mods.HasFlag(LegacyMods.HardRock)) + if (mods.HasFlagFast(LegacyMods.HardRock)) yield return new OsuModHardRock(); - if (mods.HasFlag(LegacyMods.Hidden)) + if (mods.HasFlagFast(LegacyMods.Hidden)) yield return new OsuModHidden(); - if (mods.HasFlag(LegacyMods.NoFail)) + if (mods.HasFlagFast(LegacyMods.NoFail)) yield return new OsuModNoFail(); - if (mods.HasFlag(LegacyMods.Relax)) + if (mods.HasFlagFast(LegacyMods.Relax)) yield return new OsuModRelax(); - if (mods.HasFlag(LegacyMods.SpunOut)) + if (mods.HasFlagFast(LegacyMods.SpunOut)) yield return new OsuModSpunOut(); - if (mods.HasFlag(LegacyMods.Target)) + if (mods.HasFlagFast(LegacyMods.Target)) yield return new OsuModTarget(); - if (mods.HasFlag(LegacyMods.TouchDevice)) + if (mods.HasFlagFast(LegacyMods.TouchDevice)) yield return new OsuModTouchDevice(); } @@ -184,6 +185,7 @@ namespace osu.Game.Rulesets.Osu new MultiMod(new OsuModGrow(), new OsuModDeflate()), new MultiMod(new ModWindUp(), new ModWindDown()), new OsuModTraceable(), + new OsuModBarrelRoll(), }; case ModType.System: @@ -205,6 +207,8 @@ namespace osu.Game.Rulesets.Osu public override HitObjectComposer CreateHitObjectComposer() => new OsuHitObjectComposer(this); + public override IBeatmapVerifier CreateBeatmapVerifier() => new OsuBeatmapVerifier(); + public override string Description => "osu!"; public override string ShortName => SHORT_NAME; diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs index 2883f0c187..fcb544fa5b 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs @@ -18,6 +18,6 @@ namespace osu.Game.Rulesets.Osu SliderFollowCircle, SliderBall, SliderBody, - SpinnerBody + SpinnerBody, } } diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 693943a08a..7b0cf651c8 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -71,8 +71,6 @@ namespace osu.Game.Rulesets.Osu.Replays buttonIndex = 0; - AddFrameToReplay(new OsuReplayFrame(-100000, new Vector2(256, 500))); - AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 1500, new Vector2(256, 500))); AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 1500, new Vector2(256, 500))); for (int i = 0; i < Beatmap.HitObjects.Count; i++) diff --git a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs index cf48dc053f..7d696dfb79 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs @@ -2,13 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using osu.Framework.Input.StateChanges; using osu.Framework.Utils; using osu.Game.Replays; using osu.Game.Rulesets.Replays; -using osuTK; namespace osu.Game.Rulesets.Osu.Replays { @@ -21,24 +19,11 @@ namespace osu.Game.Rulesets.Osu.Replays protected override bool IsImportant(OsuReplayFrame frame) => frame.Actions.Any(); - protected Vector2? Position - { - get - { - var frame = CurrentFrame; - - if (frame == null) - return null; - - Debug.Assert(CurrentTime != null); - - return NextFrame != null ? Interpolation.ValueAt(CurrentTime.Value, frame.Position, NextFrame.Position, frame.Time, NextFrame.Time) : frame.Position; - } - } - public override void CollectPendingInputs(List inputs) { - inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(Position ?? Vector2.Zero) }); + var position = Interpolation.ValueAt(CurrentTime, StartFrame.Position, EndFrame.Position, StartFrame.Time, EndFrame.Time); + + inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(position) }); inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); } } diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/colinear-perfect-curve-expected-conversion.json b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/colinear-perfect-curve-expected-conversion.json index 96e4bf1637..1a0bd66246 100644 --- a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/colinear-perfect-curve-expected-conversion.json +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/colinear-perfect-curve-expected-conversion.json @@ -1,5 +1,18 @@ { "Mappings": [{ + "StartTime": 114993, + "Objects": [{ + "StartTime": 114993, + "EndTime": 114993, + "X": 493, + "Y": 92 + }, { + "StartTime": 115290, + "EndTime": 115290, + "X": 451.659241, + "Y": 267.188 + }] + }, { "StartTime": 118858.0, "Objects": [{ "StartTime": 118858.0, diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/colinear-perfect-curve.osu b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/colinear-perfect-curve.osu index 8c3edc9571..dd35098502 100644 --- a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/colinear-perfect-curve.osu +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/colinear-perfect-curve.osu @@ -9,7 +9,9 @@ SliderMultiplier:1.87 SliderTickRate:1 [TimingPoints] -49051,230.769230769231,4,2,1,15,1,0 +114000,346.820809248555,4,2,1,71,1,0 +118000,230.769230769231,4,2,1,15,1,0 [HitObjects] +493,92,114993,2,0,P|472:181|442:308,1,180,12|0,0:0|0:0,0:0:0:0: 219,215,118858,2,0,P|224:170|244:-10,1,187,8|2,0:0|0:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/multi-segment-slider-expected-conversion.json b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/multi-segment-slider-expected-conversion.json new file mode 100644 index 0000000000..8a056b3039 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/multi-segment-slider-expected-conversion.json @@ -0,0 +1,36 @@ +{ + "Mappings": [{ + "StartTime": 347893, + "Objects": [{ + "StartTime": 347893, + "EndTime": 347893, + "X": 329, + "Y": 245, + "StackOffset": { + "X": 0, + "Y": 0 + } + }, + { + "StartTime": 348193, + "EndTime": 348193, + "X": 183.0447, + "Y": 245.24292, + "StackOffset": { + "X": 0, + "Y": 0 + } + }, + { + "StartTime": 348457, + "EndTime": 348457, + "X": 329, + "Y": 245, + "StackOffset": { + "X": 0, + "Y": 0 + } + } + ] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/multi-segment-slider.osu b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/multi-segment-slider.osu new file mode 100644 index 0000000000..843c32b8ef --- /dev/null +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/multi-segment-slider.osu @@ -0,0 +1,17 @@ +osu file format v14 + +[General] +Mode: 0 + +[Difficulty] +CircleSize:4 +OverallDifficulty:7 +ApproachRate:8 +SliderMultiplier:2 +SliderTickRate:1 + +[TimingPoints] +337093,300,4,2,1,40,1,0 + +[HitObjects] +329,245,347893,2,0,B|319:311|199:343|183:245|183:245,2,200,8|8|8,0:0|0:0|0:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs new file mode 100644 index 0000000000..ae8c03dad1 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs @@ -0,0 +1,132 @@ +// 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.Globalization; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public class DefaultSpinner : CompositeDrawable + { + private DrawableSpinner drawableSpinner; + + private OsuSpriteText bonusCounter; + + private Container spmContainer; + private OsuSpriteText spmCounter; + + public DefaultSpinner() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + drawableSpinner = (DrawableSpinner)drawableHitObject; + + AddRangeInternal(new Drawable[] + { + new DefaultSpinnerDisc + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + bonusCounter = new OsuSpriteText + { + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Numeric.With(size: 24), + Y = -120, + }, + spmContainer = new Container + { + Alpha = 0f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 120, + Children = new[] + { + spmCounter = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"0", + Font = OsuFont.Numeric.With(size: 24) + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"SPINS PER MINUTE", + Font = OsuFont.Numeric.With(size: 12), + Y = 30 + } + } + } + }); + } + + private IBindable gainedBonus; + private IBindable spinsPerMinute; + + protected override void LoadComplete() + { + base.LoadComplete(); + + gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy(); + gainedBonus.BindValueChanged(bonus => + { + bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo); + bonusCounter.FadeOutFromOne(1500); + bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint); + }); + + spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy(); + spinsPerMinute.BindValueChanged(spm => + { + spmCounter.Text = Math.Truncate(spm.NewValue).ToString(@"#0"); + }, true); + + drawableSpinner.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(drawableSpinner, drawableSpinner.State.Value); + } + + protected override void Update() + { + base.Update(); + + if (!spmContainer.IsPresent && drawableSpinner.Result?.TimeStarted != null) + fadeCounterOnTimeStart(); + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSpinner)) + return; + + fadeCounterOnTimeStart(); + } + + private void fadeCounterOnTimeStart() + { + if (drawableSpinner.Result?.TimeStarted is double startTime) + { + using (BeginAbsoluteSequence(startTime)) + spmContainer.FadeIn(drawableSpinner.HitObject.TimeFadeIn); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs index 667fee1495..542f3eff0d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs @@ -40,14 +40,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public DefaultSpinnerDisc() { - RelativeSizeAxes = Axes.Both; - // we are slightly bigger than our parent, to clip the top and bottom of the circle // this should probably be revisited when scaled spinners are a thing. Scale = new Vector2(initial_scale); - - Anchor = Anchor.Centre; - Origin = Anchor.Centre; } [BackgroundDependencyLoader] diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/IHasMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/IHasMainCirclePiece.cs new file mode 100644 index 0000000000..8bb7629542 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/IHasMainCirclePiece.cs @@ -0,0 +1,12 @@ +// 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.Skinning; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public interface IHasMainCirclePiece + { + SkinnableDrawable CirclePiece { get; } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs index fcbe4c1b28..b52dc749f0 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs @@ -42,6 +42,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private readonly IBindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); + private readonly IBindable armedState = new Bindable(); [Resolved] private DrawableHitObject drawableObject { get; set; } @@ -53,6 +54,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default accentColour.BindTo(drawableObject.AccentColour); indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); + armedState.BindTo(drawableObject.State); } protected override void LoadComplete() @@ -68,17 +70,19 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); - drawableObject.ApplyCustomUpdateState += updateState; - updateState(drawableObject, drawableObject.State.Value); + armedState.BindValueChanged(animate, true); } - private void updateState(DrawableHitObject drawableObject, ArmedState state) + private void animate(ValueChangedEvent state) { - using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime, true)) - { + ClearTransforms(true); + + using (BeginAbsoluteSequence(drawableObject.StateUpdateTime)) glow.FadeOut(400); - switch (state) + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) + { + switch (state.NewValue) { case ArmedState.Hit: const double flash_in = 40; @@ -91,7 +95,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default explode.FadeIn(flash_in); this.ScaleTo(1.5f, 400, Easing.OutQuad); - using (BeginDelayedSequence(flash_in, true)) + using (BeginDelayedSequence(flash_in)) { // after the flash, we can hide some elements that were behind it ring.FadeOut(); diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs index bea6186501..43d8d1e27f 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/NumberPiece.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public string Text { - get => number.Text; + get => number.Text.ToString(); set => number.Text = value; } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs index 82b677e12c..8feeca56e8 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.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.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -134,6 +135,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default /// private double? timeToAcceptAnyKeyAfter; + /// + /// The actions that were pressed in the previous frame. + /// + private readonly List lastPressedActions = new List(); + protected override void Update() { base.Update(); @@ -152,8 +158,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default { var otherKey = headCircleHitAction == OsuAction.RightButton ? OsuAction.LeftButton : OsuAction.RightButton; - // we can return to accepting all keys if the initial head circle key is the *only* key pressed, or all keys have been released. - if (actions?.Contains(otherKey) != true) + // we can start accepting any key once all other keys have been released in the previous frame. + if (!lastPressedActions.Contains(otherKey)) timeToAcceptAnyKeyAfter = Time.Current; } @@ -164,6 +170,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default lastScreenSpaceMousePosition.HasValue && followCircle.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && // valid action (actions?.Any(isValidTrackingAction) ?? false); + + lastPressedActions.Clear(); + if (actions != null) + lastPressedActions.AddRange(actions); } /// diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBonusDisplay.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBonusDisplay.cs deleted file mode 100644 index c0db6228ef..0000000000 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerBonusDisplay.cs +++ /dev/null @@ -1,47 +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.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets.Osu.Objects; - -namespace osu.Game.Rulesets.Osu.Skinning.Default -{ - /// - /// Shows incremental bonus score achieved for a spinner. - /// - public class SpinnerBonusDisplay : CompositeDrawable - { - private static readonly int score_per_tick = new SpinnerBonusTick().CreateJudgement().MaxNumericResult; - - private readonly OsuSpriteText bonusCounter; - - public SpinnerBonusDisplay() - { - AutoSizeAxes = Axes.Both; - - InternalChild = bonusCounter = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.Numeric.With(size: 24), - Alpha = 0, - }; - } - - private int displayedCount; - - public void SetBonusCount(int count) - { - if (displayedCount == count) - return; - - displayedCount = count; - bonusCounter.Text = $"{score_per_tick * count}"; - bonusCounter.FadeOutFromOne(1500); - bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCalculator.cs similarity index 61% rename from osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs rename to osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCalculator.cs index 69355f624b..a5205bbb8c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCalculator.cs @@ -1,77 +1,37 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Utils; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Skinning.Default { - public class SpinnerSpmCounter : Container + public class SpinnerSpmCalculator : Component { + private readonly Queue records = new Queue(); + private const double spm_count_duration = 595; // not using hundreds to avoid frame rounding issues + + /// + /// The resultant spins per minute value, which is updated via . + /// + public IBindable Result => result; + + private readonly Bindable result = new BindableDouble(); + [Resolved] private DrawableHitObject drawableSpinner { get; set; } - private readonly OsuSpriteText spmText; - - public SpinnerSpmCounter() - { - Children = new Drawable[] - { - spmText = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = @"0", - Font = OsuFont.Numeric.With(size: 24) - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = @"SPINS PER MINUTE", - Font = OsuFont.Numeric.With(size: 12), - Y = 30 - } - }; - } - protected override void LoadComplete() { base.LoadComplete(); drawableSpinner.HitObjectApplied += resetState; } - private double spm; - - public double SpinsPerMinute - { - get => spm; - private set - { - if (value == spm) return; - - spm = value; - spmText.Text = Math.Truncate(value).ToString(@"#0"); - } - } - - private struct RotationRecord - { - public float Rotation; - public double Time; - } - - private readonly Queue records = new Queue(); - private const double spm_count_duration = 595; // not using hundreds to avoid frame rounding issues - public void SetRotation(float currentRotation) { // Never calculate SPM by same time of record to avoid 0 / 0 = NaN or X / 0 = Infinity result. @@ -88,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default while (records.Count > 0 && Time.Current - records.Peek().Time > spm_count_duration) record = records.Dequeue(); - SpinsPerMinute = (currentRotation - record.Rotation) / (Time.Current - record.Time) * 1000 * 60 / 360; + result.Value = (currentRotation - record.Rotation) / (Time.Current - record.Time) * 1000 * 60 / 360; } records.Enqueue(new RotationRecord { Rotation = currentRotation, Time = Time.Current }); @@ -96,7 +56,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private void resetState(DrawableHitObject hitObject) { - SpinsPerMinute = 0; + result.Value = 0; records.Clear(); } @@ -107,5 +67,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default if (drawableSpinner != null) drawableSpinner.HitObjectApplied -= resetState; } + + private struct RotationRecord + { + public float Rotation; + public double Time; + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs index 314139d02a..7a8555d991 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs @@ -24,6 +24,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy [BackgroundDependencyLoader] private void load(ISkinSource skin) { + bool centre = skin.GetConfig(OsuSkinConfiguration.CursorCentre)?.Value ?? true; spin = skin.GetConfig(OsuSkinConfiguration.CursorRotate)?.Value ?? true; InternalChildren = new[] @@ -32,13 +33,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Texture = skin.GetTexture("cursor"), Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Origin = centre ? Anchor.Centre : Anchor.TopLeft, }, new NonPlayfieldSprite { Texture = skin.GetTexture("cursormiddle"), Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Origin = centre ? Anchor.Centre : Anchor.TopLeft, }, }; } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs index af9ea99232..0025576325 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs @@ -26,7 +26,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Texture = skin.GetTexture("cursortrail"); disjointTrail = skin.GetTexture("cursormiddle") == null; - Blending = !disjointTrail ? BlendingParameters.Additive : BlendingParameters.Inherit; + if (disjointTrail) + { + bool centre = skin.GetConfig(OsuSkinConfiguration.CursorCentre)?.Value ?? true; + + TrailOrigin = centre ? Anchor.Centre : Anchor.TopLeft; + Blending = BlendingParameters.Inherit; + } + else + { + Blending = BlendingParameters.Additive; + } if (Texture != null) { diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index 545e80a709..ffbeea5e0e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -40,6 +40,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private readonly Bindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); + private readonly IBindable armedState = new Bindable(); [Resolved] private DrawableHitObject drawableObject { get; set; } @@ -114,6 +115,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy accentColour.BindTo(drawableObject.AccentColour); indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); + armedState.BindTo(drawableObject.State); Texture getTextureWithFallback(string name) { @@ -139,17 +141,18 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy if (hasNumber) indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); - drawableObject.ApplyCustomUpdateState += updateState; - updateState(drawableObject, drawableObject.State.Value); + armedState.BindValueChanged(animate, true); } - private void updateState(DrawableHitObject drawableObject, ArmedState state) + private void animate(ValueChangedEvent state) { const double legacy_fade_duration = 240; - using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime, true)) + ClearTransforms(true); + + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) { - switch (state) + switch (state.NewValue) { case ArmedState.Hit: circleSprites.FadeOut(legacy_fade_duration, Easing.Out); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs index efeca53969..22fb3aab86 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs @@ -37,9 +37,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy AddInternal(scaleContainer = new Container { Scale = new Vector2(SPRITE_SCALE), - Anchor = Anchor.Centre, + Anchor = Anchor.TopCentre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, + Y = SPINNER_Y_CENTRE, Children = new Drawable[] { glow = new Sprite diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs index 4e07cb60b3..19cb55c16e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs @@ -33,47 +33,38 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { spinnerBlink = source.GetConfig(OsuSkinConfiguration.SpinnerNoBlink)?.Value != true; - AddInternal(new Container + AddRangeInternal(new Drawable[] { - // the old-style spinner relied heavily on absolute screen-space coordinate values. - // wrap everything in a container simulating absolute coords to preserve alignment - // as there are skins that depend on it. - Width = 640, - Height = 480, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] + new Sprite { - new Sprite + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-background"), + Scale = new Vector2(SPRITE_SCALE), + Y = SPINNER_Y_CENTRE, + }, + disc = new Sprite + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-circle"), + Scale = new Vector2(SPRITE_SCALE), + Y = SPINNER_Y_CENTRE, + }, + metre = new Container + { + AutoSizeAxes = Axes.Both, + // this anchor makes no sense, but that's what stable uses. + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Margin = new MarginPadding { Top = SPINNER_TOP_OFFSET }, + Masking = true, + Child = metreSprite = new Sprite { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-background"), - Scale = new Vector2(SPRITE_SCALE) - }, - disc = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-circle"), - Scale = new Vector2(SPRITE_SCALE) - }, - metre = new Container - { - AutoSizeAxes = Axes.Both, - // this anchor makes no sense, but that's what stable uses. + Texture = source.GetTexture("spinner-metre"), Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, - // adjustment for stable (metre has additional offset) - Margin = new MarginPadding { Top = 20 }, - Masking = true, - Child = metreSprite = new Sprite - { - Texture = source.GetTexture("spinner-metre"), - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Scale = new Vector2(SPRITE_SCALE) - } + Scale = new Vector2(SPRITE_SCALE) } } }); diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs index ec7ecb0d28..959589620b 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.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.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -16,50 +17,115 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public abstract class LegacySpinner : CompositeDrawable { + /// + /// All constants are in osu!stable's gamefield space, which is shifted 16px downwards. + /// This offset is negated in both osu!stable and osu!lazer to bring all constants into window-space. + /// Note: SPINNER_Y_CENTRE + SPINNER_TOP_OFFSET - Position.Y = 240 (=480/2, or half the window-space in osu!stable) + /// + protected const float SPINNER_TOP_OFFSET = 45f - 16f; + + protected const float SPINNER_Y_CENTRE = SPINNER_TOP_OFFSET + 219f; + protected const float SPRITE_SCALE = 0.625f; + private const float spm_hide_offset = 50f; + protected DrawableSpinner DrawableSpinner { get; private set; } private Sprite spin; private Sprite clear; + private LegacySpriteText bonusCounter; + + private Sprite spmBackground; + private LegacySpriteText spmCounter; + [BackgroundDependencyLoader] private void load(DrawableHitObject drawableHitObject, ISkinSource source) { - RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + // osu!stable positions spinner components in window-space (as opposed to gamefield-space). This is a 640x480 area taking up the entire screen. + // In lazer, the gamefield-space positional transformation is applied in OsuPlayfieldAdjustmentContainer, which is inverted here to make this area take up the entire window space. + Size = new Vector2(640, 480); + Position = new Vector2(0, -8f); DrawableSpinner = (DrawableSpinner)drawableHitObject; - AddRangeInternal(new[] + AddInternal(new Container { - spin = new Sprite + Depth = float.MinValue, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Depth = float.MinValue, - Texture = source.GetTexture("spinner-spin"), - Scale = new Vector2(SPRITE_SCALE), - Y = 120 - 45 // offset temporarily to avoid overlapping default spin counter - }, - clear = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Depth = float.MinValue, - Alpha = 0, - Texture = source.GetTexture("spinner-clear"), - Scale = new Vector2(SPRITE_SCALE), - Y = -60 - }, + spin = new Sprite + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-spin"), + Scale = new Vector2(SPRITE_SCALE), + Y = SPINNER_TOP_OFFSET + 335, + }, + clear = new Sprite + { + Alpha = 0, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-clear"), + Scale = new Vector2(SPRITE_SCALE), + Y = SPINNER_TOP_OFFSET + 115, + }, + bonusCounter = new LegacySpriteText(LegacyFont.Score) + { + Alpha = 0f, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Scale = new Vector2(SPRITE_SCALE), + Y = SPINNER_TOP_OFFSET + 299, + }.With(s => s.Font = s.Font.With(fixedWidth: false)), + spmBackground = new Sprite + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopLeft, + Texture = source.GetTexture("spinner-rpm"), + Scale = new Vector2(SPRITE_SCALE), + Position = new Vector2(-87, 445 + spm_hide_offset), + }, + spmCounter = new LegacySpriteText(LegacyFont.Score) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight, + Scale = new Vector2(SPRITE_SCALE * 0.9f), + Position = new Vector2(80, 448 + spm_hide_offset), + }.With(s => s.Font = s.Font.With(fixedWidth: false)), + } }); } + private IBindable gainedBonus; + private IBindable spinsPerMinute; + private readonly Bindable completed = new Bindable(); protected override void LoadComplete() { base.LoadComplete(); + gainedBonus = DrawableSpinner.GainedBonus.GetBoundCopy(); + gainedBonus.BindValueChanged(bonus => + { + bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo); + bonusCounter.FadeOutFromOne(800, Easing.Out); + bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out); + }); + + spinsPerMinute = DrawableSpinner.SpinsPerMinute.GetBoundCopy(); + spinsPerMinute.BindValueChanged(spm => + { + spmCounter.Text = Math.Truncate(spm.NewValue).ToString(@"#0"); + }, true); + completed.BindValueChanged(onCompletedChanged, true); DrawableSpinner.ApplyCustomUpdateState += UpdateStateTransforms; @@ -103,10 +169,16 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy switch (drawableHitObject) { case DrawableSpinner d: - double fadeOutLength = Math.Min(400, d.HitObject.Duration); + using (BeginAbsoluteSequence(d.HitObject.StartTime - d.HitObject.TimeFadeIn)) + { + spmBackground.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out); + spmCounter.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out); + } - using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime - fadeOutLength, true)) - spin.FadeOutFromOne(fadeOutLength); + double spinFadeOutLength = Math.Min(400, d.HitObject.Duration); + + using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime - spinFadeOutLength, true)) + spin.FadeOutFromOne(spinFadeOutLength); break; case DrawableSpinnerTick d: diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index d74f885573..ffd4f78400 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -34,93 +34,90 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy public override Drawable GetDrawableComponent(ISkinComponent component) { - if (!(component is OsuSkinComponent osuComponent)) - return null; - - switch (osuComponent.Component) + if (component is OsuSkinComponent osuComponent) { - case OsuSkinComponents.FollowPoint: - return this.GetAnimation(component.LookupName, true, false, true, startAtCurrentTime: false); + switch (osuComponent.Component) + { + case OsuSkinComponents.FollowPoint: + return this.GetAnimation(component.LookupName, true, false, true, startAtCurrentTime: false); - case OsuSkinComponents.SliderFollowCircle: - var followCircle = this.GetAnimation("sliderfollowcircle", true, true, true); - if (followCircle != null) - // follow circles are 2x the hitcircle resolution in legacy skins (since they are scaled down from >1x - followCircle.Scale *= 0.5f; - return followCircle; + case OsuSkinComponents.SliderFollowCircle: + var followCircle = this.GetAnimation("sliderfollowcircle", true, true, true); + if (followCircle != null) + // follow circles are 2x the hitcircle resolution in legacy skins (since they are scaled down from >1x + followCircle.Scale *= 0.5f; + return followCircle; - case OsuSkinComponents.SliderBall: - var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: ""); + case OsuSkinComponents.SliderBall: + var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: ""); - // todo: slider ball has a custom frame delay based on velocity - // Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME); + // todo: slider ball has a custom frame delay based on velocity + // Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME); - if (sliderBallContent != null) - return new LegacySliderBall(sliderBallContent); + if (sliderBallContent != null) + return new LegacySliderBall(sliderBallContent); - return null; + return null; - case OsuSkinComponents.SliderBody: - if (hasHitCircle.Value) - return new LegacySliderBody(); + case OsuSkinComponents.SliderBody: + if (hasHitCircle.Value) + return new LegacySliderBody(); - return null; + return null; - case OsuSkinComponents.SliderTailHitCircle: - if (hasHitCircle.Value) - return new LegacyMainCirclePiece("sliderendcircle", false); + case OsuSkinComponents.SliderTailHitCircle: + if (hasHitCircle.Value) + return new LegacyMainCirclePiece("sliderendcircle", false); - return null; + return null; - case OsuSkinComponents.SliderHeadHitCircle: - if (hasHitCircle.Value) - return new LegacyMainCirclePiece("sliderstartcircle"); + case OsuSkinComponents.SliderHeadHitCircle: + if (hasHitCircle.Value) + return new LegacyMainCirclePiece("sliderstartcircle"); - return null; + return null; - case OsuSkinComponents.HitCircle: - if (hasHitCircle.Value) - return new LegacyMainCirclePiece(); + case OsuSkinComponents.HitCircle: + if (hasHitCircle.Value) + return new LegacyMainCirclePiece(); - return null; + return null; - case OsuSkinComponents.Cursor: - if (Source.GetTexture("cursor") != null) - return new LegacyCursor(); + case OsuSkinComponents.Cursor: + if (Source.GetTexture("cursor") != null) + return new LegacyCursor(); - return null; + return null; - case OsuSkinComponents.CursorTrail: - if (Source.GetTexture("cursortrail") != null) - return new LegacyCursorTrail(); + case OsuSkinComponents.CursorTrail: + if (Source.GetTexture("cursortrail") != null) + return new LegacyCursorTrail(); - return null; + return null; - case OsuSkinComponents.HitCircleText: - var font = GetConfig(OsuSkinConfiguration.HitCirclePrefix)?.Value ?? "default"; - var overlap = GetConfig(OsuSkinConfiguration.HitCircleOverlap)?.Value ?? -2; + case OsuSkinComponents.HitCircleText: + if (!this.HasFont(LegacyFont.HitCircle)) + return null; - return !this.HasFont(font) - ? null - : new LegacySpriteText(Source, font) + return new LegacySpriteText(LegacyFont.HitCircle) { // stable applies a blanket 0.8x scale to hitcircle fonts Scale = new Vector2(0.8f), - Spacing = new Vector2(-overlap, 0) }; - case OsuSkinComponents.SpinnerBody: - bool hasBackground = Source.GetTexture("spinner-background") != null; + case OsuSkinComponents.SpinnerBody: + bool hasBackground = Source.GetTexture("spinner-background") != null; - if (Source.GetTexture("spinner-top") != null && !hasBackground) - return new LegacyNewStyleSpinner(); - else if (hasBackground) - return new LegacyOldStyleSpinner(); + if (Source.GetTexture("spinner-top") != null && !hasBackground) + return new LegacyNewStyleSpinner(); + else if (hasBackground) + return new LegacyOldStyleSpinner(); - return null; + return null; + } } - return null; + return Source.GetDrawableComponent(component); } public override IBindable GetConfig(TLookup lookup) diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs index 63c9b53278..6953e66b5c 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs @@ -5,11 +5,10 @@ namespace osu.Game.Rulesets.Osu.Skinning { public enum OsuSkinConfiguration { - HitCirclePrefix, - HitCircleOverlap, SliderBorderSize, SliderPathRadius, AllowSliderBallTint, + CursorCentre, CursorExpand, CursorRotate, HitCircleOverlayAboveNumber, diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs index 88c855d768..cb769c31b8 100644 --- a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs +++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs @@ -166,7 +166,7 @@ namespace osu.Game.Rulesets.Osu.Statistics var point = new HitPoint(pointType, this) { - Colour = pointType == HitPointType.Hit ? new Color4(102, 255, 204, 255) : new Color4(255, 102, 102, 255) + BaseColour = pointType == HitPointType.Hit ? new Color4(102, 255, 204, 255) : new Color4(255, 102, 102, 255) }; points[r][c] = point; @@ -234,6 +234,11 @@ namespace osu.Game.Rulesets.Osu.Statistics private class HitPoint : Circle { + /// + /// The base colour which will be lightened/darkened depending on the value of this . + /// + public Color4 BaseColour; + private readonly HitPointType pointType; private readonly AccuracyHeatmap heatmap; @@ -284,7 +289,7 @@ namespace osu.Game.Rulesets.Osu.Statistics Alpha = Math.Min(amount / lighten_cutoff, 1); if (pointType == HitPointType.Hit) - Colour = ((Color4)Colour).Lighten(Math.Max(0, amount - lighten_cutoff)); + Colour = BaseColour.Lighten(Math.Max(0, amount - lighten_cutoff)); } } diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 0b30c28b8d..7f86e9daf7 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; using osu.Framework.Allocation; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Batches; using osu.Framework.Graphics.OpenGL.Vertices; @@ -31,6 +32,18 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private double timeOffset; private float time; + private Anchor trailOrigin = Anchor.Centre; + + protected Anchor TrailOrigin + { + get => trailOrigin; + set + { + trailOrigin = value; + Invalidate(Invalidation.DrawNode); + } + } + public CursorTrail() { // as we are currently very dependent on having a running clock, let's make our own clock for the time being. @@ -197,6 +210,8 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private readonly TrailPart[] parts = new TrailPart[max_sprites]; private Vector2 size; + private Vector2 originPosition; + private readonly QuadBatch vertexBatch = new QuadBatch(max_sprites, 1); public TrailDrawNode(CursorTrail source) @@ -213,6 +228,18 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor size = Source.partSize; time = Source.time; + originPosition = Vector2.Zero; + + if (Source.TrailOrigin.HasFlagFast(Anchor.x1)) + originPosition.X = 0.5f; + else if (Source.TrailOrigin.HasFlagFast(Anchor.x2)) + originPosition.X = 1f; + + if (Source.TrailOrigin.HasFlagFast(Anchor.y1)) + originPosition.Y = 0.5f; + else if (Source.TrailOrigin.HasFlagFast(Anchor.y2)) + originPosition.Y = 1f; + Source.parts.CopyTo(parts, 0); } @@ -237,7 +264,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X - size.X / 2, part.Position.Y + size.Y / 2), + Position = new Vector2(part.Position.X - size.X * originPosition.X, part.Position.Y + size.Y * (1 - originPosition.Y)), TexturePosition = textureRect.BottomLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomLeft.Linear, @@ -246,7 +273,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X + size.X / 2, part.Position.Y + size.Y / 2), + Position = new Vector2(part.Position.X + size.X * (1 - originPosition.X), part.Position.Y + size.Y * (1 - originPosition.Y)), TexturePosition = textureRect.BottomRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.BottomRight.Linear, @@ -255,7 +282,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X + size.X / 2, part.Position.Y - size.Y / 2), + Position = new Vector2(part.Position.X + size.X * (1 - originPosition.X), part.Position.Y - size.Y * originPosition.Y), TexturePosition = textureRect.TopRight, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopRight.Linear, @@ -264,7 +291,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Add(new TexturedTrailVertex { - Position = new Vector2(part.Position.X - size.X / 2, part.Position.Y - size.Y / 2), + Position = new Vector2(part.Position.X - size.X * originPosition.X, part.Position.Y - size.Y * originPosition.Y), TexturePosition = textureRect.TopLeft, TextureRect = new Vector4(0, 0, 1, 1), Colour = DrawColourInfo.Colour.TopLeft.Linear, diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index b1069149f3..8993a9b18a 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -42,6 +42,9 @@ namespace osu.Game.Rulesets.Osu.UI public OsuPlayfield() { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + InternalChildren = new Drawable[] { playfieldBorder = new PlayfieldBorder { RelativeSizeAxes = Axes.Both }, @@ -57,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.UI var hitWindows = new OsuHitWindows(); foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) - poolDictionary.Add(result, new DrawableJudgementPool(result, onJudgmentLoaded)); + poolDictionary.Add(result, new DrawableJudgementPool(result, onJudgementLoaded)); AddRangeInternal(poolDictionary.Values); @@ -99,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.UI } } - private void onJudgmentLoaded(DrawableOsuJudgement judgement) + private void onJudgementLoaded(DrawableOsuJudgement judgement) { judgementAboveHitObjectLayer.Add(judgement.GetProxyAboveHitObjectsContent()); } diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index ec7751d2b4..27d48d1296 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -33,7 +33,6 @@ namespace osu.Game.Rulesets.Osu.UI { Add(cursorScaleContainer = new Container { - RelativePositionAxes = Axes.Both, Child = clickToResumeCursor = new OsuClickToResumeCursor { ResumeRequested = Resume } }); } @@ -43,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.UI base.PopIn(); GameplayCursor.ActiveCursor.Hide(); - cursorScaleContainer.MoveTo(GameplayCursor.ActiveCursor.Position); + cursorScaleContainer.Position = ToLocalSpace(GameplayCursor.ActiveCursor.ScreenSpaceDrawQuad.Centre); clickToResumeCursor.Appear(); if (localCursorContainer == null) diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj b/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj index 392442b713..4d4dabebe6 100644 --- a/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj +++ b/osu.Game.Rulesets.Taiko.Tests.Android/osu.Game.Rulesets.Taiko.Tests.Android.csproj @@ -14,6 +14,11 @@ Properties\AndroidManifest.xml armeabi-v7a;x86;arm64-v8a + + None + cjk;mideast;other;rare;west + true + @@ -35,5 +40,10 @@ osu.Game + + + 5.0.0 + + \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs index a1d000386f..ac01508081 100644 --- a/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs +++ b/osu.Game.Rulesets.Taiko.Tests/HitObjectApplicationTestScene.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Tests [SetUpSteps] public void SetUp() - => AddStep("clear SHOC", () => hitObjectContainer.Clear(false)); + => AddStep("clear SHOC", () => hitObjectContainer.Clear()); protected void AddHitObject(DrawableHitObject hitObject) => AddStep("add to SHOC", () => hitObjectContainer.Add(hitObject)); diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs index ff309f278e..f9b8e9a985 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [BackgroundDependencyLoader] private void load() { - AddStep("Bar line", () => SetContents(() => + AddStep("Bar line", () => SetContents(_ => { ScrollingHitObjectContainer hoc; @@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning return cont; })); - AddStep("Bar line (major)", () => SetContents(() => + AddStep("Bar line (major)", () => SetContents(_ => { ScrollingHitObjectContainer hoc; diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs index 44646e5fc9..26a4e85fe5 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [BackgroundDependencyLoader] private void load() { - AddStep("Drum roll", () => SetContents(() => + AddStep("Drum roll", () => SetContents(_ => { var hoc = new ScrollingHitObjectContainer(); @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning return hoc; })); - AddStep("Drum roll (strong)", () => SetContents(() => + AddStep("Drum roll (strong)", () => SetContents(_ => { var hoc = new ScrollingHitObjectContainer(); diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs index 9930d97d31..c4ee68206c 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs @@ -17,25 +17,25 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [BackgroundDependencyLoader] private void load() { - AddStep("Centre hit", () => SetContents(() => new DrawableHit(createHitAtCurrentTime()) + AddStep("Centre hit", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, })); - AddStep("Centre hit (strong)", () => SetContents(() => new DrawableHit(createHitAtCurrentTime(true)) + AddStep("Centre hit (strong)", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime(true)) { Anchor = Anchor.Centre, Origin = Anchor.Centre, })); - AddStep("Rim hit", () => SetContents(() => new DrawableHit(createHitAtCurrentTime()) + AddStep("Rim hit", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, })); - AddStep("Rim hit (strong)", () => SetContents(() => new DrawableHit(createHitAtCurrentTime(true)) + AddStep("Rim hit (strong)", () => SetContents(_ => new DrawableHit(createHitAtCurrentTime(true)) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs index 8d1aafdcc2..b976735223 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs @@ -54,16 +54,16 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning { AddStep("set beatmap", () => setBeatmap()); - AddStep("clear state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Clear))); - AddStep("idle state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Idle))); - AddStep("kiai state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Kiai))); - AddStep("fail state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Fail))); + AddStep("clear state", () => SetContents(_ => new TaikoMascotAnimation(TaikoMascotAnimationState.Clear))); + AddStep("idle state", () => SetContents(_ => new TaikoMascotAnimation(TaikoMascotAnimationState.Idle))); + AddStep("kiai state", () => SetContents(_ => new TaikoMascotAnimation(TaikoMascotAnimationState.Kiai))); + AddStep("fail state", () => SetContents(_ => new TaikoMascotAnimation(TaikoMascotAnimationState.Fail))); } [Test] public void TestInitialState() { - AddStep("create mascot", () => SetContents(() => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both })); + AddStep("create mascot", () => SetContents(_ => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both })); AddAssert("mascot initially idle", () => allMascotsIn(TaikoMascotAnimationState.Idle)); } @@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning { AddStep("set beatmap", () => setBeatmap()); - AddStep("create mascot", () => SetContents(() => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both })); + AddStep("create mascot", () => SetContents(_ => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both })); AddStep("set clear state", () => mascots.ForEach(mascot => mascot.State.Value = TaikoMascotAnimationState.Clear)); AddStep("miss", () => mascots.ForEach(mascot => mascot.LastResult.Value = new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss })); @@ -181,7 +181,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning { Beatmap.Value.Track.Start(); - SetContents(() => + SetContents(_ => { var ruleset = new TaikoRuleset(); return new DrawableTaikoRuleset(ruleset, Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo)); diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs index fecb5d4a74..1cba6c9008 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs @@ -2,8 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; @@ -13,19 +17,24 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestFixture] public class TestSceneHitExplosion : TaikoSkinnableTestScene { + protected override double TimePerAction => 100; + [Test] public void TestNormalHit() { - AddStep("Great", () => SetContents(() => getContentFor(createHit(HitResult.Great)))); - AddStep("Ok", () => SetContents(() => getContentFor(createHit(HitResult.Ok)))); - AddStep("Miss", () => SetContents(() => getContentFor(createHit(HitResult.Miss)))); + AddStep("Great", () => SetContents(_ => getContentFor(createHit(HitResult.Great)))); + AddStep("Ok", () => SetContents(_ => getContentFor(createHit(HitResult.Ok)))); + AddStep("Miss", () => SetContents(_ => getContentFor(createHit(HitResult.Miss)))); } - [Test] - public void TestStrongHit([Values(false, true)] bool hitBoth) + [TestCase(HitResult.Great)] + [TestCase(HitResult.Ok)] + public void TestStrongHit(HitResult type) { - AddStep("Great", () => SetContents(() => getContentFor(createStrongHit(HitResult.Great, hitBoth)))); - AddStep("Good", () => SetContents(() => getContentFor(createStrongHit(HitResult.Ok, hitBoth)))); + AddStep("create hit", () => SetContents(_ => getContentFor(createStrongHit(type)))); + AddStep("visualise second hit", + () => this.ChildrenOfType() + .ForEach(e => e.VisualiseSecondHit(new JudgementResult(new HitObject { StartTime = Time.Current }, new Judgement())))); } private Drawable getContentFor(DrawableTestHit hit) @@ -38,17 +47,17 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning // the hit needs to be added to hierarchy in order for nested objects to be created correctly. // setting zero alpha is supposed to prevent the test from looking broken. hit.With(h => h.Alpha = 0), - new HitExplosion(hit, hit.Type) + new HitExplosion(hit.Type) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - } + }.With(explosion => explosion.Apply(hit)) } }; } private DrawableTestHit createHit(HitResult type) => new DrawableTestHit(new Hit { StartTime = Time.Current }, type); - private DrawableTestHit createStrongHit(HitResult type, bool hitBoth) => new DrawableTestStrongHit(Time.Current, type, hitBoth); + private DrawableTestHit createStrongHit(HitResult type) => new DrawableTestStrongHit(Time.Current, type); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs index fa6c9da174..055a292fe8 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [BackgroundDependencyLoader] private void load() { - SetContents(() => new TaikoInputManager(new RulesetInfo { ID = 1 }) + SetContents(_ => new TaikoInputManager(new TaikoRuleset().RulesetInfo) { RelativeSizeAxes = Axes.Both, Child = new Container diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneKiaiHitExplosion.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneKiaiHitExplosion.cs index b558709592..419e100296 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneKiaiHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneKiaiHitExplosion.cs @@ -15,8 +15,8 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [Test] public void TestKiaiHits() { - AddStep("rim hit", () => SetContents(() => getContentFor(createHit(HitType.Rim)))); - AddStep("centre hit", () => SetContents(() => getContentFor(createHit(HitType.Centre)))); + AddStep("rim hit", () => SetContents(_ => getContentFor(createHit(HitType.Rim)))); + AddStep("centre hit", () => SetContents(_ => getContentFor(createHit(HitType.Centre)))); } private Drawable getContentFor(DrawableTestHit hit) diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs index 7b7e2c43d1..f96297a06d 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Beatmap.Value.Track.Start(); }); - AddStep("Load playfield", () => SetContents(() => new TaikoPlayfield(new ControlPointInfo()) + AddStep("Load playfield", () => SetContents(_ => new TaikoPlayfield(new ControlPointInfo()) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs index 4ae3cbd418..9882c7bc90 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs @@ -5,6 +5,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Legacy; using osu.Game.Skinning; @@ -19,7 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning public TestSceneTaikoScroller() { - AddStep("Load scroller", () => SetContents(() => + AddStep("Load scroller", () => SetContents(_ => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) { Clock = new FramedClock(clock), @@ -27,7 +28,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning })); AddToggleStep("Toggle passing", passing => this.ChildrenOfType().ForEach(s => s.LastResult.Value = - new JudgementResult(null, new Judgement()) { Type = passing ? HitResult.Great : HitResult.Miss })); + new JudgementResult(new HitObject(), new Judgement()) { Type = passing ? HitResult.Great : HitResult.Miss })); AddToggleStep("toggle playback direction", reversed => this.reversed = reversed); } diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index eb21c02d5f..dd3c6b317a 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs @@ -19,8 +19,8 @@ namespace osu.Game.Rulesets.Taiko.Tests public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(3.1473940254109078d, "diffcalc-test")] - [TestCase(3.1473940254109078d, "diffcalc-test-strong")] + [TestCase(3.1704781712282624d, "diffcalc-test")] + [TestCase(3.1704781712282624d, "diffcalc-test-strong")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new TaikoModDoubleTime()); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs index a970965141..f33c738b04 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneBarLineApplication.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Taiko.Tests { StartTime = 400, Major = true - }), null)); + }))); AddHitObject(barLine); RemoveHitObject(barLine); @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Taiko.Tests { StartTime = 200, Major = false - }), null)); + }))); AddHitObject(barLine); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs index 54450e27db..c389a05566 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumRollApplication.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Duration = 500, IsStrong = false, TickRate = 2 - }), null)); + }))); AddHitObject(drumRoll); RemoveHitObject(drumRoll); @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Duration = 400, IsStrong = true, TickRate = 16 - }), null)); + }))); AddHitObject(drumRoll); } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs index 52fd440857..c2f251fcb6 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHitApplication.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Type = HitType.Rim, IsStrong = false, StartTime = 300 - }), null)); + }))); AddHitObject(hit); RemoveHitObject(hit); @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Type = HitType.Centre, IsStrong = true, StartTime = 500 - }), null)); + }))); AddHitObject(hit); } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs index 7695ca067b..87c936d386 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs @@ -11,7 +11,6 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; using osu.Game.Rulesets.Taiko.Objects; @@ -108,12 +107,12 @@ namespace osu.Game.Rulesets.Taiko.Tests { HitResult hitResult = RNG.Next(2) == 0 ? HitResult.Ok : HitResult.Great; - Hit hit = new Hit(); + Hit hit = new Hit { StartTime = DrawableRuleset.Playfield.Time.Current }; var h = new DrawableTestHit(hit, kiai: kiai) { X = RNG.NextSingle(hitResult == HitResult.Ok ? -0.1f : -0.05f, hitResult == HitResult.Ok ? 0.1f : 0.05f) }; DrawableRuleset.Playfield.Add(h); - ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(hit, new TaikoJudgement()) { Type = hitResult }); } private void addStrongHitJudgement(bool kiai) @@ -122,6 +121,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Hit hit = new Hit { + StartTime = DrawableRuleset.Playfield.Time.Current, IsStrong = true, Samples = createSamples(strong: true) }; @@ -129,8 +129,8 @@ namespace osu.Game.Rulesets.Taiko.Tests DrawableRuleset.Playfield.Add(h); - ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); - ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h.NestedHitObjects.Single(), new JudgementResult(new HitObject(), new TaikoStrongJudgement()) { Type = HitResult.Great }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(hit, new TaikoJudgement()) { Type = hitResult }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h.NestedHitObjects.Single(), new JudgementResult(hit.NestedHitObjects.Single(), new TaikoStrongJudgement()) { Type = HitResult.Great }); } private void addMissJudgement() diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs index 7089ea6619..221d715a35 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Taiko.Tests { protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); - protected override IResourceStore Resources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneTaikoHitObjectSamples))); + protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneTaikoHitObjectSamples))); [TestCase("taiko-normal-hitnormal")] [TestCase("normal-hitnormal")] diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj index 2b084f3bee..8fb167ba10 100644 --- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj +++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj @@ -2,8 +2,8 @@ - - + + diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index b51f096d7d..90c99316b1 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -79,8 +79,6 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps // Old osu! used hit sounding to determine various hit type information IList samples = obj.Samples; - bool strong = samples.Any(s => s.Name == HitSampleInfo.HIT_FINISH); - switch (obj) { case IHasDistance distanceData: @@ -94,15 +92,11 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps for (double j = obj.StartTime; j <= obj.StartTime + taikoDuration + tickSpacing / 8; j += tickSpacing) { IList currentSamples = allSamples[i]; - bool isRim = currentSamples.Any(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE); - strong = currentSamples.Any(s => s.Name == HitSampleInfo.HIT_FINISH); yield return new Hit { StartTime = j, - Type = isRim ? HitType.Rim : HitType.Centre, Samples = currentSamples, - IsStrong = strong }; i = (i + 1) % allSamples.Count; @@ -117,7 +111,6 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps { StartTime = obj.StartTime, Samples = obj.Samples, - IsStrong = strong, Duration = taikoDuration, TickRate = beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate == 3 ? 3 : 4 }; @@ -143,16 +136,10 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps default: { - bool isRimDefinition(HitSampleInfo s) => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE; - - bool isRim = samples.Any(isRimDefinition); - yield return new Hit { StartTime = obj.StartTime, - Type = isRim ? HitType.Rim : HitType.Centre, Samples = samples, - IsStrong = strong }; break; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs index cc0738e252..769d021362 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// /// Calculates the colour coefficient of taiko difficulty. /// - public class Colour : Skill + public class Colour : StrainSkill { protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 0.4; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs index f2b8309ac5..a32f6ebe0d 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// /// Calculates the rhythm coefficient of taiko difficulty. /// - public class Rhythm : Skill + public class Rhythm : StrainSkill { protected override double SkillMultiplier => 10; protected override double StrainDecayBase => 0; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index c34cce0cd6..4cceadb23f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// /// The reference play style chosen uses two hands, with full alternating (the hand changes after every hit). /// - public class Stamina : Skill + public class Stamina : StrainSkill { protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 0.4; diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index fc198d2493..6b3e31c5d5 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -133,11 +133,16 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { List peaks = new List(); - for (int i = 0; i < colour.StrainPeaks.Count; i++) + var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); + var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); + var staminaRightPeaks = staminaRight.GetCurrentStrainPeaks().ToList(); + var staminaLeftPeaks = staminaLeft.GetCurrentStrainPeaks().ToList(); + + for (int i = 0; i < colourPeaks.Count; i++) { - double colourPeak = colour.StrainPeaks[i] * colour_skill_multiplier; - double rhythmPeak = rhythm.StrainPeaks[i] * rhythm_skill_multiplier; - double staminaPeak = (staminaRight.StrainPeaks[i] + staminaLeft.StrainPeaks[i]) * stamina_skill_multiplier * staminaPenalty; + double colourPeak = colourPeaks[i] * colour_skill_multiplier; + double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; + double staminaPeak = (staminaRightPeaks[i] + staminaLeftPeaks[i]) * stamina_skill_multiplier * staminaPenalty; peaks.Add(norm(2, colourPeak, rhythmPeak, staminaPeak)); } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs index 17e7fb81f6..0d0fd136a7 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs @@ -14,10 +14,10 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { private readonly HitPiece piece; - private static Hit hit; + public new Hit HitObject => (Hit)base.HitObject; public HitPlacementBlueprint() - : base(hit = new Hit()) + : base(new Hit()) { InternalChild = piece = new HitPiece { @@ -30,12 +30,12 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints switch (e.Button) { case MouseButton.Left: - hit.Type = HitType.Centre; + HitObject.Type = HitType.Centre; EndPlacement(true); return true; case MouseButton.Right: - hit.Type = HitType.Rim; + HitObject.Type = HitType.Rim; EndPlacement(true); return true; } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs index 62f69122cc..01b90c4bca 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs @@ -3,14 +3,14 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects; using osuTK; namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { - public class TaikoSelectionBlueprint : OverlaySelectionBlueprint + public class TaikoSelectionBlueprint : HitObjectSelectionBlueprint { - public TaikoSelectionBlueprint(DrawableHitObject hitObject) + public TaikoSelectionBlueprint(HitObject hitObject) : base(hitObject) { RelativeSizeAxes = Axes.None; diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index e53b331f46..59249e6bf4 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints { base.UpdateTimeAndPosition(result); - if (PlacementActive) + if (PlacementActive == PlacementState.Active) { if (result.Time is double dragTime) { diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs index 8b41448c9d..a465638779 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Edit.Blueprints; using osu.Game.Screens.Edit.Compose.Components; @@ -15,9 +15,9 @@ namespace osu.Game.Rulesets.Taiko.Edit { } - protected override SelectionHandler CreateSelectionHandler() => new TaikoSelectionHandler(); + protected override SelectionHandler CreateSelectionHandler() => new TaikoSelectionHandler(); - public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => + public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) => new TaikoSelectionBlueprint(hitObject); } } diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index 3fbcee44af..ab3b729307 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -8,12 +8,13 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Taiko.Edit { - public class TaikoSelectionHandler : SelectionHandler + public class TaikoSelectionHandler : EditorSelectionHandler { private readonly Bindable selectionRimState = new Bindable(); private readonly Bindable selectionStrongState = new Bindable(); @@ -52,44 +53,43 @@ namespace osu.Game.Rulesets.Taiko.Edit public void SetStrongState(bool state) { - var hits = EditorBeatmap.SelectedHitObjects.OfType(); - - EditorBeatmap.BeginChange(); - - foreach (var h in hits) + EditorBeatmap.PerformOnSelection(h => { - if (h.IsStrong != state) - { - h.IsStrong = state; - EditorBeatmap.Update(h); - } - } + if (!(h is Hit taikoHit)) return; - EditorBeatmap.EndChange(); + if (taikoHit.IsStrong != state) + { + taikoHit.IsStrong = state; + EditorBeatmap.Update(taikoHit); + } + }); } public void SetRimState(bool state) { - var hits = EditorBeatmap.SelectedHitObjects.OfType(); - - EditorBeatmap.BeginChange(); - - foreach (var h in hits) - h.Type = state ? HitType.Rim : HitType.Centre; - - EditorBeatmap.EndChange(); + EditorBeatmap.PerformOnSelection(h => + { + if (h is Hit taikoHit) + { + taikoHit.Type = state ? HitType.Rim : HitType.Centre; + EditorBeatmap.Update(h); + } + }); } - protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable selection) + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { - if (selection.All(s => s.HitObject is Hit)) - yield return new TernaryStateMenuItem("Rim") { State = { BindTarget = selectionRimState } }; + if (selection.All(s => s.Item is Hit)) + yield return new TernaryStateToggleMenuItem("Rim") { State = { BindTarget = selectionRimState } }; - if (selection.All(s => s.HitObject is TaikoHitObject)) - yield return new TernaryStateMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; + if (selection.All(s => s.Item is TaikoHitObject)) + yield return new TernaryStateToggleMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; + + foreach (var item in base.GetContextMenuItemsForSelection(selection)) + yield return item; } - public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; protected override void UpdateTernaryStates() { diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs new file mode 100644 index 0000000000..5a4d18be98 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs @@ -0,0 +1,11 @@ +// 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.Mods; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModClassic : ModClassic + { + } +} diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs index a6f902208c..7739ecaf5b 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Mods { @@ -10,5 +11,13 @@ namespace osu.Game.Rulesets.Taiko.Mods public override string Description => @"Beats fade out before you hit them!"; public override double ScoreMultiplier => 1.06; public override bool HasImplementation => false; + + protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) + { + } + + protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) + { + } } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs index 1cf19ac18e..a22f189d5e 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.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; +using System.Linq; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -12,6 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Mods public class TaikoModRandom : ModRandom, IApplicableToBeatmap { public override string Description => @"Shuffle around the colours!"; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(TaikoModSwap)).ToArray(); public void ApplyToBeatmap(IBeatmap beatmap) { diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs new file mode 100644 index 0000000000..3cb337c41d --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs @@ -0,0 +1,33 @@ +// 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.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModSwap : Mod, IApplicableToBeatmap + { + public override string Name => "Swap"; + public override string Acronym => "SW"; + public override string Description => @"Dons become kats, kats become dons"; + public override ModType Type => ModType.Conversion; + public override double ScoreMultiplier => 1; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModRandom)).ToArray(); + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var taikoBeatmap = (TaikoBeatmap)beatmap; + + foreach (var obj in taikoBeatmap.HitObjects) + { + if (obj is Hit hit) + hit.Type = hit.Type == HitType.Centre ? HitType.Rim : HitType.Centre; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index 38cda69a46..1e9fc187eb 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -52,23 +52,18 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override void OnApply() { type.BindTo(HitObject.TypeBindable); - type.BindValueChanged(_ => - { - updateActionsFromType(); - - // will overwrite samples, should only be called on subsequent changes - // after the initial application. - updateSamplesFromTypeChange(); - - RecreatePieces(); - }); - - // action update also has to happen immediately on application. - updateActionsFromType(); + // this doesn't need to be run inline as RecreatePieces is called by the base call below. + type.BindValueChanged(_ => Scheduler.AddOnce(RecreatePieces)); base.OnApply(); } + protected override void RecreatePieces() + { + updateActionsFromType(); + base.RecreatePieces(); + } + protected override void OnFree() { base.OnFree(); @@ -83,33 +78,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables validActionPressed = pressHandledThisFrame = false; } - private HitSampleInfo[] getRimSamples() => HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE).ToArray(); - - protected override void LoadSamples() - { - base.LoadSamples(); - - type.Value = getRimSamples().Any() ? HitType.Rim : HitType.Centre; - } - - private void updateSamplesFromTypeChange() - { - var rimSamples = getRimSamples(); - - bool isRimType = HitObject.Type == HitType.Rim; - - if (isRimType != rimSamples.Any()) - { - if (isRimType) - HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP)); - else - { - foreach (var sample in rimSamples) - HitObject.Samples.Remove(sample); - } - } - } - private void updateActionsFromType() { HitActions = diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 6041eccb51..6a8d8a611c 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -137,7 +137,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { Size = BaseSize = new Vector2(TaikoHitObject.DEFAULT_SIZE); - MainPiece?.Expire(); + if (MainPiece != null) + Content.Remove(MainPiece); + Content.Add(MainPiece = CreateMainPiece()); } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs index 4f1523eb3f..70d4371e99 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoStrongableHitObject.cs @@ -1,11 +1,9 @@ // 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 JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; -using osu.Game.Audio; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osuTK; @@ -29,14 +27,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables protected override void OnApply() { isStrong.BindTo(HitObject.IsStrongBindable); - isStrong.BindValueChanged(_ => - { - // will overwrite samples, should only be called on subsequent changes - // after the initial application. - updateSamplesFromStrong(); - - RecreatePieces(); - }); + // this doesn't need to be run inline as RecreatePieces is called by the base call below. + isStrong.BindValueChanged(_ => Scheduler.AddOnce(RecreatePieces)); base.OnApply(); } @@ -50,30 +42,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables isStrong.UnbindEvents(); } - private HitSampleInfo[] getStrongSamples() => HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_FINISH).ToArray(); - - protected override void LoadSamples() - { - base.LoadSamples(); - isStrong.Value = getStrongSamples().Any(); - } - - private void updateSamplesFromStrong() - { - var strongSamples = getStrongSamples(); - - if (isStrong.Value != strongSamples.Any()) - { - if (isStrong.Value) - HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH)); - else - { - foreach (var sample in strongSamples) - HitObject.Samples.Remove(sample); - } - } - } - protected override void RecreatePieces() { base.RecreatePieces(); diff --git a/osu.Game.Rulesets.Taiko/Objects/Hit.cs b/osu.Game.Rulesets.Taiko/Objects/Hit.cs index 1b51288605..b4ed242893 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Hit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Hit.cs @@ -1,7 +1,9 @@ // 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.Bindables; +using osu.Game.Audio; namespace osu.Game.Rulesets.Taiko.Objects { @@ -18,6 +20,40 @@ namespace osu.Game.Rulesets.Taiko.Objects set => TypeBindable.Value = value; } + public Hit() + { + TypeBindable.BindValueChanged(_ => updateSamplesFromType()); + SamplesBindable.BindCollectionChanged((_, __) => updateTypeFromSamples()); + } + + private void updateTypeFromSamples() + { + Type = getRimSamples().Any() ? HitType.Rim : HitType.Centre; + } + + /// + /// Returns an array of any samples which would cause this object to be a "rim" type hit. + /// + private HitSampleInfo[] getRimSamples() => Samples.Where(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE).ToArray(); + + private void updateSamplesFromType() + { + var rimSamples = getRimSamples(); + + bool isRimType = Type == HitType.Rim; + + if (isRimType != rimSamples.Any()) + { + if (isRimType) + Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP)); + else + { + foreach (var sample in rimSamples) + Samples.Remove(sample); + } + } + } + protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime }; public class StrongNestedHit : StrongNestedHitObject diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs index fcd055bcec..6c17573b50 100644 --- a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs @@ -1,8 +1,10 @@ // 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 System.Threading; using osu.Framework.Bindables; +using osu.Game.Audio; using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Taiko.Objects @@ -34,6 +36,35 @@ namespace osu.Game.Rulesets.Taiko.Objects set => IsStrongBindable.Value = value; } + protected TaikoStrongableHitObject() + { + IsStrongBindable.BindValueChanged(_ => updateSamplesFromType()); + SamplesBindable.BindCollectionChanged((_, __) => updateTypeFromSamples()); + } + + private void updateTypeFromSamples() + { + IsStrong = getStrongSamples().Any(); + } + + private void updateSamplesFromType() + { + var strongSamples = getStrongSamples(); + + if (IsStrongBindable.Value != strongSamples.Any()) + { + if (IsStrongBindable.Value) + Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH)); + else + { + foreach (var sample in strongSamples) + Samples.Remove(sample); + } + } + } + + private HitSampleInfo[] getStrongSamples() => Samples.Where(s => s.Name == HitSampleInfo.HIT_FINISH).ToArray(); + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { base.CreateNestedHitObjects(cancellationToken); diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs index a3dbe672a4..5fd281f9fa 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs @@ -2,10 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; -using osu.Game.Replays; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Taiko.Beatmaps; @@ -13,7 +11,7 @@ using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Taiko.Replays { - public class TaikoAutoGenerator : AutoGenerator + public class TaikoAutoGenerator : AutoGenerator { public new TaikoBeatmap Beatmap => (TaikoBeatmap)base.Beatmap; @@ -22,20 +20,15 @@ namespace osu.Game.Rulesets.Taiko.Replays public TaikoAutoGenerator(IBeatmap beatmap) : base(beatmap) { - Replay = new Replay(); } - protected Replay Replay; - protected List Frames => Replay.Frames; - - public override Replay Generate() + protected override void GenerateFrames() { if (Beatmap.HitObjects.Count == 0) - return Replay; + return; bool hitButton = true; - Frames.Add(new TaikoReplayFrame(-100000)); Frames.Add(new TaikoReplayFrame(Beatmap.HitObjects[0].StartTime - 1000)); for (int i = 0; i < Beatmap.HitObjects.Count; i++) @@ -129,8 +122,6 @@ namespace osu.Game.Rulesets.Taiko.Replays hitButton = !hitButton; } - - return Replay; } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs index 651cdd6438..21bd35ad22 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs @@ -1,22 +1,22 @@ // 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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class LegacyHitExplosion : CompositeDrawable + public class LegacyHitExplosion : CompositeDrawable, IAnimatableHitExplosion { private readonly Drawable sprite; - private readonly Drawable strongSprite; - private DrawableStrongNestedHit nestedStrongHit; - private bool switchedToStrongSprite; + [CanBeNull] + private readonly Drawable strongSprite; /// /// Creates a new legacy hit explosion. @@ -27,14 +27,14 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy /// /// The normal legacy explosion sprite. /// The strong legacy explosion sprite. - public LegacyHitExplosion(Drawable sprite, Drawable strongSprite = null) + public LegacyHitExplosion(Drawable sprite, [CanBeNull] Drawable strongSprite = null) { this.sprite = sprite; this.strongSprite = strongSprite; } [BackgroundDependencyLoader] - private void load(DrawableHitObject judgedObject) + private void load() { Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -56,45 +56,30 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy s.Origin = Anchor.Centre; })); } - - if (judgedObject is DrawableHit hit) - nestedStrongHit = hit.NestedHitObjects.SingleOrDefault() as DrawableStrongNestedHit; } - protected override void LoadComplete() + public void Animate(DrawableHitObject drawableHitObject) { - base.LoadComplete(); - const double animation_time = 120; + (sprite as IFramedAnimation)?.GotoFrame(0); + (strongSprite as IFramedAnimation)?.GotoFrame(0); + this.FadeInFromZero(animation_time).Then().FadeOut(animation_time * 1.5); this.ScaleTo(0.6f) .Then().ScaleTo(1.1f, animation_time * 0.8) .Then().ScaleTo(0.9f, animation_time * 0.4) .Then().ScaleTo(1f, animation_time * 0.2); - - Expire(true); } - protected override void Update() + public void AnimateSecondHit() { - base.Update(); + if (strongSprite == null) + return; - if (shouldSwitchToStrongSprite() && !switchedToStrongSprite) - { - sprite.FadeOut(50, Easing.OutQuint); - strongSprite.FadeIn(50, Easing.OutQuint); - switchedToStrongSprite = true; - } - } - - private bool shouldSwitchToStrongSprite() - { - if (nestedStrongHit == null || strongSprite == null) - return false; - - return nestedStrongHit.IsHit; + sprite.FadeOut(50, Easing.OutQuint); + strongSprite.FadeIn(50, Easing.OutQuint); } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs index eb92097204..6fc59ea0e8 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyTaikoScroller.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy base.Update(); // store X before checking wide enough so if we perform layout there is no positional discrepancy. - float currentX = (InternalChildren?.FirstOrDefault()?.X ?? 0) - (float)Clock.ElapsedFrameTime * 0.1f; + float currentX = (InternalChildren.FirstOrDefault()?.X ?? 0) - (float)Clock.ElapsedFrameTime * 0.1f; // ensure we have enough sprites if (!InternalChildren.Any() diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index 9f29675230..e0557c8617 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -35,101 +35,101 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { // if a taiko skin is providing explosion sprites, hide the judgements completely if (hasExplosion.Value) - return Drawable.Empty(); + return Drawable.Empty().With(d => d.Expire()); } - if (!(component is TaikoSkinComponent taikoComponent)) - return null; - - switch (taikoComponent.Component) + if (component is TaikoSkinComponent taikoComponent) { - case TaikoSkinComponents.DrumRollBody: - if (GetTexture("taiko-roll-middle") != null) - return new LegacyDrumRoll(); + switch (taikoComponent.Component) + { + case TaikoSkinComponents.DrumRollBody: + if (GetTexture("taiko-roll-middle") != null) + return new LegacyDrumRoll(); - return null; + return null; - case TaikoSkinComponents.InputDrum: - if (GetTexture("taiko-bar-left") != null) - return new LegacyInputDrum(); + case TaikoSkinComponents.InputDrum: + if (GetTexture("taiko-bar-left") != null) + return new LegacyInputDrum(); - return null; + return null; - case TaikoSkinComponents.CentreHit: - case TaikoSkinComponents.RimHit: + case TaikoSkinComponents.CentreHit: + case TaikoSkinComponents.RimHit: - if (GetTexture("taikohitcircle") != null) - return new LegacyHit(taikoComponent.Component); + if (GetTexture("taikohitcircle") != null) + return new LegacyHit(taikoComponent.Component); - return null; + return null; - case TaikoSkinComponents.DrumRollTick: - return this.GetAnimation("sliderscorepoint", false, false); + case TaikoSkinComponents.DrumRollTick: + return this.GetAnimation("sliderscorepoint", false, false); - case TaikoSkinComponents.HitTarget: - if (GetTexture("taikobigcircle") != null) - return new TaikoLegacyHitTarget(); + case TaikoSkinComponents.HitTarget: + if (GetTexture("taikobigcircle") != null) + return new TaikoLegacyHitTarget(); - return null; + return null; - case TaikoSkinComponents.PlayfieldBackgroundRight: - if (GetTexture("taiko-bar-right") != null) - return new TaikoLegacyPlayfieldBackgroundRight(); + case TaikoSkinComponents.PlayfieldBackgroundRight: + if (GetTexture("taiko-bar-right") != null) + return new TaikoLegacyPlayfieldBackgroundRight(); - return null; + return null; - case TaikoSkinComponents.PlayfieldBackgroundLeft: - // This is displayed inside LegacyInputDrum. It is required to be there for layout purposes (can be seen on legacy skins). - if (GetTexture("taiko-bar-right") != null) - return Drawable.Empty(); + case TaikoSkinComponents.PlayfieldBackgroundLeft: + // This is displayed inside LegacyInputDrum. It is required to be there for layout purposes (can be seen on legacy skins). + if (GetTexture("taiko-bar-right") != null) + return Drawable.Empty(); - return null; + return null; - case TaikoSkinComponents.BarLine: - if (GetTexture("taiko-barline") != null) - return new LegacyBarLine(); + case TaikoSkinComponents.BarLine: + if (GetTexture("taiko-barline") != null) + return new LegacyBarLine(); - return null; + return null; - case TaikoSkinComponents.TaikoExplosionMiss: + case TaikoSkinComponents.TaikoExplosionMiss: - var missSprite = this.GetAnimation(getHitName(taikoComponent.Component), true, false); - if (missSprite != null) - return new LegacyHitExplosion(missSprite); + var missSprite = this.GetAnimation(getHitName(taikoComponent.Component), true, false); + if (missSprite != null) + return new LegacyHitExplosion(missSprite); - return null; + return null; - case TaikoSkinComponents.TaikoExplosionOk: - case TaikoSkinComponents.TaikoExplosionGreat: + case TaikoSkinComponents.TaikoExplosionOk: + case TaikoSkinComponents.TaikoExplosionGreat: - var hitName = getHitName(taikoComponent.Component); - var hitSprite = this.GetAnimation(hitName, true, false); + var hitName = getHitName(taikoComponent.Component); + var hitSprite = this.GetAnimation(hitName, true, false); - if (hitSprite != null) - { - var strongHitSprite = this.GetAnimation($"{hitName}k", true, false); + if (hitSprite != null) + { + var strongHitSprite = this.GetAnimation($"{hitName}k", true, false); - return new LegacyHitExplosion(hitSprite, strongHitSprite); - } + return new LegacyHitExplosion(hitSprite, strongHitSprite); + } - return null; + return null; - case TaikoSkinComponents.TaikoExplosionKiai: - // suppress the default kiai explosion if the skin brings its own sprites. - // the drawable needs to expire as soon as possible to avoid accumulating empty drawables on the playfield. - if (hasExplosion.Value) - return Drawable.Empty().With(d => d.LifetimeEnd = double.MinValue); + case TaikoSkinComponents.TaikoExplosionKiai: + // suppress the default kiai explosion if the skin brings its own sprites. + // the drawable needs to expire as soon as possible to avoid accumulating empty drawables on the playfield. + if (hasExplosion.Value) + return Drawable.Empty().With(d => d.Expire()); - return null; + return null; - case TaikoSkinComponents.Scroller: - if (GetTexture("taiko-slider") != null) - return new LegacyTaikoScroller(); + case TaikoSkinComponents.Scroller: + if (GetTexture("taiko-slider") != null) + return new LegacyTaikoScroller(); - return null; + return null; - case TaikoSkinComponents.Mascot: - return new DrawableTaikoMascot(); + case TaikoSkinComponents.Mascot: + return new DrawableTaikoMascot(); + } } return Source.GetDrawableComponent(component); @@ -152,7 +152,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy throw new ArgumentOutOfRangeException(nameof(component), $"Invalid component type: {component}"); } - public override Sample GetSample(ISampleInfo sampleInfo) => Source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); + public override ISample GetSample(ISampleInfo sampleInfo) => Source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); public override IBindable GetConfig(TLookup lookup) => Source.GetConfig(lookup); diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index f2b5d195b4..5854d4770c 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -22,6 +22,7 @@ using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Scoring; using System; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Taiko.Edit; using osu.Game.Rulesets.Taiko.Objects; @@ -57,43 +58,43 @@ namespace osu.Game.Rulesets.Taiko public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { - if (mods.HasFlag(LegacyMods.Nightcore)) + if (mods.HasFlagFast(LegacyMods.Nightcore)) yield return new TaikoModNightcore(); - else if (mods.HasFlag(LegacyMods.DoubleTime)) + else if (mods.HasFlagFast(LegacyMods.DoubleTime)) yield return new TaikoModDoubleTime(); - if (mods.HasFlag(LegacyMods.Perfect)) + if (mods.HasFlagFast(LegacyMods.Perfect)) yield return new TaikoModPerfect(); - else if (mods.HasFlag(LegacyMods.SuddenDeath)) + else if (mods.HasFlagFast(LegacyMods.SuddenDeath)) yield return new TaikoModSuddenDeath(); - if (mods.HasFlag(LegacyMods.Cinema)) + if (mods.HasFlagFast(LegacyMods.Cinema)) yield return new TaikoModCinema(); - else if (mods.HasFlag(LegacyMods.Autoplay)) + else if (mods.HasFlagFast(LegacyMods.Autoplay)) yield return new TaikoModAutoplay(); - if (mods.HasFlag(LegacyMods.Easy)) + if (mods.HasFlagFast(LegacyMods.Easy)) yield return new TaikoModEasy(); - if (mods.HasFlag(LegacyMods.Flashlight)) + if (mods.HasFlagFast(LegacyMods.Flashlight)) yield return new TaikoModFlashlight(); - if (mods.HasFlag(LegacyMods.HalfTime)) + if (mods.HasFlagFast(LegacyMods.HalfTime)) yield return new TaikoModHalfTime(); - if (mods.HasFlag(LegacyMods.HardRock)) + if (mods.HasFlagFast(LegacyMods.HardRock)) yield return new TaikoModHardRock(); - if (mods.HasFlag(LegacyMods.Hidden)) + if (mods.HasFlagFast(LegacyMods.Hidden)) yield return new TaikoModHidden(); - if (mods.HasFlag(LegacyMods.NoFail)) + if (mods.HasFlagFast(LegacyMods.NoFail)) yield return new TaikoModNoFail(); - if (mods.HasFlag(LegacyMods.Relax)) + if (mods.HasFlagFast(LegacyMods.Relax)) yield return new TaikoModRelax(); - if (mods.HasFlag(LegacyMods.Random)) + if (mods.HasFlagFast(LegacyMods.Random)) yield return new TaikoModRandom(); } @@ -134,6 +135,8 @@ namespace osu.Game.Rulesets.Taiko { new TaikoModRandom(), new TaikoModDifficultyAdjust(), + new TaikoModClassic(), + new TaikoModSwap(), }; case ModType.Automation: diff --git a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs index 3bd20e4bb4..91e844187a 100644 --- a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,19 +14,23 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.UI { - internal class DefaultHitExplosion : CircularContainer + internal class DefaultHitExplosion : CircularContainer, IAnimatableHitExplosion { - private readonly DrawableHitObject judgedObject; private readonly HitResult result; - public DefaultHitExplosion(DrawableHitObject judgedObject, HitResult result) + [CanBeNull] + private Box body; + + [Resolved] + private OsuColour colours { get; set; } + + public DefaultHitExplosion(HitResult result) { - this.judgedObject = judgedObject; this.result = result; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { RelativeSizeAxes = Axes.Both; @@ -40,26 +45,36 @@ namespace osu.Game.Rulesets.Taiko.UI if (!result.IsHit()) return; - bool isRim = (judgedObject.HitObject as Hit)?.Type == HitType.Rim; - InternalChildren = new[] { - new Box + body = new Box { RelativeSizeAxes = Axes.Both, - Colour = isRim ? colours.BlueDarker : colours.PinkDarker, } }; + + updateColour(); } - protected override void LoadComplete() + private void updateColour([CanBeNull] DrawableHitObject judgedObject = null) { - base.LoadComplete(); + if (body == null) + return; + + bool isRim = (judgedObject?.HitObject as Hit)?.Type == HitType.Rim; + body.Colour = isRim ? colours.BlueDarker : colours.PinkDarker; + } + + public void Animate(DrawableHitObject drawableHitObject) + { + updateColour(drawableHitObject); this.ScaleTo(3f, 1000, Easing.OutQuint); this.FadeOut(500); + } - Expire(true); + public void AnimateSecondHit() + { } } } diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs index b5e35f88b5..1ad1e4495c 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs @@ -3,7 +3,6 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.UI { @@ -12,16 +11,6 @@ namespace osu.Game.Rulesets.Taiko.UI /// public class DrawableTaikoJudgement : DrawableJudgement { - /// - /// Creates a new judgement text. - /// - /// The object which is being judged. - /// The judgement to visualise. - public DrawableTaikoJudgement(JudgementResult result, DrawableHitObject judgedObject) - : base(result, judgedObject) - { - } - protected override void ApplyHitAnimations() { this.MoveToY(-100, 500); diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs index d1fb3348b9..8f5e9e54ab 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; @@ -16,31 +18,37 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// A circle explodes from the hit target to indicate a hitobject has been hit. /// - internal class HitExplosion : CircularContainer + internal class HitExplosion : PoolableDrawable { public override bool RemoveWhenNotAlive => true; - - [Cached(typeof(DrawableHitObject))] - public readonly DrawableHitObject JudgedObject; + public override bool RemoveCompletedTransforms => false; private readonly HitResult result; + private double? secondHitTime; + + [CanBeNull] + public DrawableHitObject JudgedObject; + private SkinnableDrawable skinnable; - public override double LifetimeStart => skinnable.Drawable.LifetimeStart; - - public override double LifetimeEnd => skinnable.Drawable.LifetimeEnd; - - public HitExplosion(DrawableHitObject judgedObject, HitResult result) + /// + /// This constructor only exists to meet the new() type constraint of . + /// + public HitExplosion() + : this(HitResult.Great) + { + } + + public HitExplosion(HitResult result) { - JudgedObject = judgedObject; this.result = result; Anchor = Anchor.Centre; Origin = Anchor.Centre; - RelativeSizeAxes = Axes.Both; Size = new Vector2(TaikoHitObject.DEFAULT_SIZE); + RelativeSizeAxes = Axes.Both; RelativePositionAxes = Axes.Both; } @@ -48,7 +56,47 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load() { - Child = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(result)), _ => new DefaultHitExplosion(JudgedObject, result)); + InternalChild = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(result)), _ => new DefaultHitExplosion(result)); + skinnable.OnSkinChanged += runAnimation; + } + + public void Apply([CanBeNull] DrawableHitObject drawableHitObject) + { + JudgedObject = drawableHitObject; + secondHitTime = null; + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + runAnimation(); + } + + private void runAnimation() + { + if (JudgedObject?.Result == null) + return; + + double resultTime = JudgedObject.Result.TimeAbsolute; + + LifetimeStart = resultTime; + + ApplyTransformsAt(double.MinValue, true); + ClearTransforms(true); + + using (BeginAbsoluteSequence(resultTime)) + (skinnable.Drawable as IAnimatableHitExplosion)?.Animate(JudgedObject); + + if (secondHitTime != null) + { + using (BeginAbsoluteSequence(secondHitTime.Value)) + { + this.ResizeTo(new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE), 50); + (skinnable.Drawable as IAnimatableHitExplosion)?.AnimateSecondHit(); + } + } + + LifetimeEnd = skinnable.Drawable.LatestTransformEndTime; } private static TaikoSkinComponents getComponentName(HitResult result) @@ -68,12 +116,10 @@ namespace osu.Game.Rulesets.Taiko.UI throw new ArgumentOutOfRangeException(nameof(result), $"Invalid result type: {result}"); } - /// - /// Transforms this hit explosion to visualise a secondary hit. - /// - public void VisualiseSecondHit() + public void VisualiseSecondHit(JudgementResult judgementResult) { - this.ResizeTo(new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE), 50); + secondHitTime = judgementResult.TimeAbsolute; + runAnimation(); } } } diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs new file mode 100644 index 0000000000..badf34554c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs @@ -0,0 +1,24 @@ +// 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.Graphics.Pooling; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Taiko.UI +{ + /// + /// Pool for hit explosions of a specific type. + /// + internal class HitExplosionPool : DrawablePool + { + private readonly HitResult hitResult; + + public HitExplosionPool(HitResult hitResult) + : base(15) + { + this.hitResult = hitResult; + } + + protected override HitExplosion CreateNewDrawable() => new HitExplosion(hitResult); + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/IAnimatableHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/IAnimatableHitExplosion.cs new file mode 100644 index 0000000000..cf0f5f9fb6 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/IAnimatableHitExplosion.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.UI +{ + /// + /// A skinnable element of a hit explosion that supports playing an animation from the current point in time. + /// + public interface IAnimatableHitExplosion + { + /// + /// Shows the hit explosion for the supplied . + /// + void Animate(DrawableHitObject drawableHitObject); + + /// + /// Transforms the hit explosion to visualise a secondary hit. + /// + void AnimateSecondHit(); + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 148ec7755e..46dafc3a30 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; @@ -17,6 +19,7 @@ using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Rulesets.Taiko.Judgements; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Skinning; using osuTK; @@ -38,6 +41,9 @@ namespace osu.Game.Rulesets.Taiko.UI internal Drawable HitTarget; private SkinnableDrawable mascot; + private readonly IDictionary> judgementPools = new Dictionary>(); + private readonly IDictionary explosionPools = new Dictionary(); + private ProxyContainer topLevelHitContainer; private Container rightArea; private Container leftArea; @@ -159,6 +165,17 @@ namespace osu.Game.Rulesets.Taiko.UI RegisterPool(5); RegisterPool(100); + + var hitWindows = new TaikoHitWindows(); + + foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => hitWindows.IsHitResultAllowed(r))) + { + judgementPools.Add(result, new DrawablePool(15)); + explosionPools.Add(result, new HitExplosionPool(result)); + } + + AddRangeInternal(judgementPools.Values); + AddRangeInternal(explosionPools.Values); } protected override void LoadComplete() @@ -270,7 +287,7 @@ namespace osu.Game.Rulesets.Taiko.UI { case TaikoStrongJudgement _: if (result.IsHit) - hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).ParentHitObject)?.VisualiseSecondHit(); + hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).ParentHitObject)?.VisualiseSecondHit(result); break; case TaikoDrumRollTickJudgement _: @@ -283,13 +300,15 @@ namespace osu.Game.Rulesets.Taiko.UI break; default: - judgementContainer.Add(new DrawableTaikoJudgement(result, judgedObject) + judgementContainer.Add(judgementPools[result.Type].Get(j => { - Anchor = result.IsHit ? Anchor.TopLeft : Anchor.CentreLeft, - Origin = result.IsHit ? Anchor.BottomCentre : Anchor.Centre, - RelativePositionAxes = Axes.X, - X = result.IsHit ? judgedObject.Position.X : 0, - }); + j.Apply(result, judgedObject); + + j.Anchor = result.IsHit ? Anchor.TopLeft : Anchor.CentreLeft; + j.Origin = result.IsHit ? Anchor.BottomCentre : Anchor.Centre; + j.RelativePositionAxes = Axes.X; + j.X = result.IsHit ? judgedObject.Position.X : 0; + })); var type = (judgedObject.HitObject as Hit)?.Type ?? HitType.Centre; addExplosion(judgedObject, result.Type, type); @@ -302,7 +321,8 @@ namespace osu.Game.Rulesets.Taiko.UI private void addExplosion(DrawableHitObject drawableObject, HitResult result, HitType type) { - hitExplosionContainer.Add(new HitExplosion(drawableObject, result)); + hitExplosionContainer.Add(explosionPools[result] + .Get(explosion => explosion.Apply(drawableObject))); if (drawableObject.HitObject.Kiai) kiaiExplosionContainer.Add(new KiaiHitExplosion(drawableObject, type)); } diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj index 19e36a63f1..b45a3249ff 100644 --- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj +++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj @@ -20,6 +20,14 @@ + + $(NoWarn);CA2007 + + + None + cjk;mideast;other;rare;west + true + %(RecursiveDir)%(Filename)%(Extension) @@ -71,7 +79,10 @@ - + + + 5.0.0 + - \ No newline at end of file + diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj index 67b2298f4c..97df9b2cd5 100644 --- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj +++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj @@ -21,6 +21,9 @@ %(RecursiveDir)%(Filename)%(Extension) + + $(NoWarn);CA2007 + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D} @@ -45,7 +48,7 @@ - + - \ No newline at end of file + diff --git a/osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs b/osu.Game.Tests/Beatmaps/BeatmapDifficultyCacheTest.cs similarity index 98% rename from osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs rename to osu.Game.Tests/Beatmaps/BeatmapDifficultyCacheTest.cs index 70503bec7a..d407c0663f 100644 --- a/osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs +++ b/osu.Game.Tests/Beatmaps/BeatmapDifficultyCacheTest.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu.Mods; namespace osu.Game.Tests.Beatmaps { [TestFixture] - public class BeatmapDifficultyManagerTest + public class BeatmapDifficultyCacheTest { [Test] public void TestKeyEqualsWithDifferentModInstances() diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 4b9e9dd88c..0f82492e51 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -707,6 +707,69 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(third.ControlPoints[5].Type.Value, Is.EqualTo(null)); Assert.That(third.ControlPoints[6].Position.Value, Is.EqualTo(new Vector2(480, 0))); Assert.That(third.ControlPoints[6].Type.Value, Is.EqualTo(null)); + + // Last control point duplicated + var fourth = ((IHasPath)decoded.HitObjects[3]).Path; + + Assert.That(fourth.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(fourth.ControlPoints[0].Type.Value, Is.EqualTo(PathType.Bezier)); + Assert.That(fourth.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(1, 1))); + Assert.That(fourth.ControlPoints[1].Type.Value, Is.EqualTo(null)); + Assert.That(fourth.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(2, 2))); + Assert.That(fourth.ControlPoints[2].Type.Value, Is.EqualTo(null)); + Assert.That(fourth.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(3, 3))); + Assert.That(fourth.ControlPoints[3].Type.Value, Is.EqualTo(null)); + Assert.That(fourth.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(3, 3))); + Assert.That(fourth.ControlPoints[4].Type.Value, Is.EqualTo(null)); + + // Last control point in segment duplicated + var fifth = ((IHasPath)decoded.HitObjects[4]).Path; + + Assert.That(fifth.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(fifth.ControlPoints[0].Type.Value, Is.EqualTo(PathType.Bezier)); + Assert.That(fifth.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(1, 1))); + Assert.That(fifth.ControlPoints[1].Type.Value, Is.EqualTo(null)); + Assert.That(fifth.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(2, 2))); + Assert.That(fifth.ControlPoints[2].Type.Value, Is.EqualTo(null)); + Assert.That(fifth.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(3, 3))); + Assert.That(fifth.ControlPoints[3].Type.Value, Is.EqualTo(null)); + Assert.That(fifth.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(3, 3))); + Assert.That(fifth.ControlPoints[4].Type.Value, Is.EqualTo(null)); + + Assert.That(fifth.ControlPoints[5].Position.Value, Is.EqualTo(new Vector2(4, 4))); + Assert.That(fifth.ControlPoints[5].Type.Value, Is.EqualTo(PathType.Bezier)); + Assert.That(fifth.ControlPoints[6].Position.Value, Is.EqualTo(new Vector2(5, 5))); + Assert.That(fifth.ControlPoints[6].Type.Value, Is.EqualTo(null)); + + // Implicit perfect-curve multi-segment(Should convert to bezier to match stable) + var sixth = ((IHasPath)decoded.HitObjects[5]).Path; + + Assert.That(sixth.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(sixth.ControlPoints[0].Type.Value == PathType.Bezier); + Assert.That(sixth.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(75, 145))); + Assert.That(sixth.ControlPoints[1].Type.Value == null); + Assert.That(sixth.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(170, 75))); + + Assert.That(sixth.ControlPoints[2].Type.Value == PathType.Bezier); + Assert.That(sixth.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(300, 145))); + Assert.That(sixth.ControlPoints[3].Type.Value == null); + Assert.That(sixth.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(410, 20))); + Assert.That(sixth.ControlPoints[4].Type.Value == null); + + // Explicit perfect-curve multi-segment(Should not convert to bezier) + var seventh = ((IHasPath)decoded.HitObjects[6]).Path; + + Assert.That(seventh.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(seventh.ControlPoints[0].Type.Value == PathType.PerfectCurve); + Assert.That(seventh.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(75, 145))); + Assert.That(seventh.ControlPoints[1].Type.Value == null); + Assert.That(seventh.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(170, 75))); + + Assert.That(seventh.ControlPoints[2].Type.Value == PathType.PerfectCurve); + Assert.That(seventh.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(300, 145))); + Assert.That(seventh.ControlPoints[3].Type.Value == null); + Assert.That(seventh.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(410, 20))); + Assert.That(seventh.ControlPoints[4].Type.Value == null); } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index 0784109158..059432eeaf 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -18,10 +18,14 @@ using osu.Game.IO; using osu.Game.IO.Serialization; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Taiko; using osu.Game.Skinning; using osu.Game.Tests.Resources; +using osuTK; namespace osu.Game.Tests.Beatmaps.Formats { @@ -45,6 +49,33 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.IsTrue(areComboColoursEqual(decodedAfterEncode.beatmapSkin.Configuration, decoded.beatmapSkin.Configuration)); } + [Test] + public void TestEncodeMultiSegmentSliderWithFloatingPointError() + { + var beatmap = new Beatmap + { + HitObjects = + { + new Slider + { + Position = new Vector2(0.6f), + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.Bezier), + new PathControlPoint(new Vector2(0.5f)), + new PathControlPoint(new Vector2(0.51f)), // This is actually on the same position as the previous one in legacy beatmaps (truncated to int). + new PathControlPoint(new Vector2(1f), PathType.Bezier), + new PathControlPoint(new Vector2(2f)) + }) + }, + } + }; + + var decodedAfterEncode = decodeFromLegacy(encodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty))), string.Empty); + var decodedSlider = (Slider)decodedAfterEncode.beatmap.HitObjects[0]; + Assert.That(decodedSlider.Path.ControlPoints.Count, Is.EqualTo(5)); + } + private bool areComboColoursEqual(IHasComboColours a, IHasComboColours b) { // equal to null, no need to SequenceEqual @@ -137,6 +168,10 @@ namespace osu.Game.Tests.Beatmaps.Formats protected override Texture GetBackground() => throw new NotImplementedException(); protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + + protected override ISkin GetSkin() => throw new NotImplementedException(); + + public override Stream GetStream(string storagePath) => throw new NotImplementedException(); } } } diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs index c32e359de6..0d117f8755 100644 --- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs @@ -12,6 +12,7 @@ using osu.Framework.Platform; using osu.Game.IPC; using osu.Framework.Allocation; using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; @@ -264,7 +265,7 @@ namespace osu.Game.Tests.Beatmaps.IO // change filename var firstFile = new FileInfo(Directory.GetFiles(extractedFolder).First()); - firstFile.MoveTo(Path.Combine(firstFile.DirectoryName, $"{firstFile.Name}-changed{firstFile.Extension}")); + firstFile.MoveTo(Path.Combine(firstFile.DirectoryName.AsNonNull(), $"{firstFile.Name}-changed{firstFile.Extension}")); using (var zip = ZipArchive.Create()) { @@ -852,6 +853,21 @@ namespace osu.Game.Tests.Beatmaps.IO } } + public static async Task LoadQuickOszIntoOsu(OsuGameBase osu) + { + var temp = TestResources.GetQuickTestBeatmapForImport(); + + var manager = osu.Dependencies.Get(); + + var importedSet = await manager.Import(new ImportTask(temp)); + + ensureLoaded(osu); + + waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); + + return manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.ID); + } + public static async Task LoadOszIntoOsu(OsuGameBase osu, string path = null, bool virtualTrack = false) { var temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack); diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index b80da928c8..ecb37706b0 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -24,10 +24,10 @@ namespace osu.Game.Tests.Chat [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123#osu/456")] [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123#osu/456?whatever")] [TestCase(LinkAction.OpenBeatmap, "456", "https://dev.ppy.sh/beatmapsets/123/456")] - [TestCase(LinkAction.External, null, "https://dev.ppy.sh/beatmapsets/abc/def")] + [TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/abc/def", "https://dev.ppy.sh/beatmapsets/abc/def")] [TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123")] [TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123/whatever")] - [TestCase(LinkAction.External, null, "https://dev.ppy.sh/beatmapsets/abc")] + [TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/abc", "https://dev.ppy.sh/beatmapsets/abc")] public void TestBeatmapLinks(LinkAction expectedAction, string expectedArg, string link) { MessageFormatter.WebsiteRootUrl = "dev.ppy.sh"; @@ -489,5 +489,23 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(result.Links[2].Url, "\uD83D\uDE00"); Assert.AreEqual(result.Links[3].Url, "\uD83D\uDE20"); } + + [Test] + public void TestAbsoluteExternalLinks() + { + LinkDetails result = MessageFormatter.GetLinkDetails("https://google.com"); + + Assert.AreEqual(LinkAction.External, result.Action); + Assert.AreEqual("https://google.com", result.Argument); + } + + [Test] + public void TestRelativeExternalLinks() + { + LinkDetails result = MessageFormatter.GetLinkDetails("/relative"); + + Assert.AreEqual(LinkAction.External, result.Action); + Assert.AreEqual("/relative", result.Argument); + } } } diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs index a8ee1bcc2e..a47631a83b 100644 --- a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs +++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs @@ -23,7 +23,7 @@ namespace osu.Game.Tests.Collections.IO { var osu = LoadOsuIntoHost(host); - await osu.CollectionManager.Import(new MemoryStream()); + await importCollectionsFromStream(osu, new MemoryStream()); Assert.That(osu.CollectionManager.Collections.Count, Is.Zero); } @@ -43,7 +43,7 @@ namespace osu.Game.Tests.Collections.IO { var osu = LoadOsuIntoHost(host); - await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); + await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); @@ -69,7 +69,7 @@ namespace osu.Game.Tests.Collections.IO { var osu = LoadOsuIntoHost(host, true); - await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); + await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); @@ -110,7 +110,7 @@ namespace osu.Game.Tests.Collections.IO ms.Seek(0, SeekOrigin.Begin); - await osu.CollectionManager.Import(ms); + await importCollectionsFromStream(osu, ms); } Assert.That(host.UpdateThread.Running, Is.True); @@ -134,7 +134,7 @@ namespace osu.Game.Tests.Collections.IO { var osu = LoadOsuIntoHost(host, true); - await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); + await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db")); // Move first beatmap from second collection into the first. osu.CollectionManager.Collections[0].Beatmaps.Add(osu.CollectionManager.Collections[1].Beatmaps[0]); @@ -169,5 +169,12 @@ namespace osu.Game.Tests.Collections.IO } } } + + private static async Task importCollectionsFromStream(TestOsuGameBase osu, Stream stream) + { + // intentionally spin this up on a separate task to avoid disposal deadlocks. + // see https://github.com/EventStore/EventStore/issues/1179 + await Task.Run(() => osu.CollectionManager.Import(stream).Wait()); + } } } diff --git a/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs new file mode 100644 index 0000000000..39fbf11d51 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckAudioQualityTest.cs @@ -0,0 +1,120 @@ +// 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 Moq; +using NUnit.Framework; +using osu.Framework.Audio.Track; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckAudioQualityTest + { + private CheckAudioQuality check; + private IBeatmap beatmap; + + [SetUp] + public void Setup() + { + check = new CheckAudioQuality(); + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata { AudioFile = "abc123.jpg" } + } + }; + } + + [Test] + public void TestMissing() + { + // While this is a problem, it is out of scope for this check and is caught by a different one. + beatmap.Metadata.AudioFile = null; + + var mock = new Mock(); + mock.SetupGet(w => w.Beatmap).Returns(beatmap); + mock.SetupGet(w => w.Track).Returns((Track)null); + + Assert.That(check.Run(new BeatmapVerifierContext(beatmap, mock.Object)), Is.Empty); + } + + [Test] + public void TestAcceptable() + { + var context = getContext(192); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestNullBitrate() + { + var context = getContext(null); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateNoBitrate); + } + + [Test] + public void TestZeroBitrate() + { + var context = getContext(0); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateNoBitrate); + } + + [Test] + public void TestTooHighBitrate() + { + var context = getContext(320); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooHighBitrate); + } + + [Test] + public void TestTooLowBitrate() + { + var context = getContext(64); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioQuality.IssueTemplateTooLowBitrate); + } + + private BeatmapVerifierContext getContext(int? audioBitrate) + { + return new BeatmapVerifierContext(beatmap, getMockWorkingBeatmap(audioBitrate).Object); + } + + /// + /// Returns the mock of the working beatmap with the given audio properties. + /// + /// The bitrate of the audio file the beatmap uses. + private Mock getMockWorkingBeatmap(int? audioBitrate) + { + var mockTrack = new Mock(); + mockTrack.SetupGet(t => t.Bitrate).Returns(audioBitrate); + + var mockWorkingBeatmap = new Mock(); + mockWorkingBeatmap.SetupGet(w => w.Beatmap).Returns(beatmap); + mockWorkingBeatmap.SetupGet(w => w.Track).Returns(mockTrack.Object); + + return mockWorkingBeatmap; + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs new file mode 100644 index 0000000000..3424cfe732 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs @@ -0,0 +1,136 @@ +// 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 JetBrains.Annotations; +using Moq; +using NUnit.Framework; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using FileInfo = osu.Game.IO.FileInfo; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckBackgroundQualityTest + { + private CheckBackgroundQuality check; + private IBeatmap beatmap; + + [SetUp] + public void Setup() + { + check = new CheckBackgroundQuality(); + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata { BackgroundFile = "abc123.jpg" }, + BeatmapSet = new BeatmapSetInfo + { + Files = new List(new[] + { + new BeatmapSetFileInfo + { + Filename = "abc123.jpg", + FileInfo = new FileInfo + { + Hash = "abcdef" + } + } + }) + } + } + }; + } + + [Test] + public void TestMissing() + { + // While this is a problem, it is out of scope for this check and is caught by a different one. + beatmap.Metadata.BackgroundFile = null; + var context = getContext(null, System.Array.Empty()); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestAcceptable() + { + var context = getContext(new Texture(1920, 1080)); + + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestTooHighResolution() + { + var context = getContext(new Texture(3840, 2160)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckBackgroundQuality.IssueTemplateTooHighResolution); + } + + [Test] + public void TestLowResolution() + { + var context = getContext(new Texture(640, 480)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckBackgroundQuality.IssueTemplateLowResolution); + } + + [Test] + public void TestTooLowResolution() + { + var context = getContext(new Texture(100, 100)); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckBackgroundQuality.IssueTemplateTooLowResolution); + } + + [Test] + public void TestTooUncompressed() + { + var context = getContext(new Texture(1920, 1080), new byte[1024 * 1024 * 3]); + + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckBackgroundQuality.IssueTemplateTooUncompressed); + } + + private BeatmapVerifierContext getContext(Texture background, [CanBeNull] byte[] fileBytes = null) + { + return new BeatmapVerifierContext(beatmap, getMockWorkingBeatmap(background, fileBytes).Object); + } + + /// + /// Returns the mock of the working beatmap with the given background and filesize. + /// + /// The texture of the background. + /// The bytes that represent the background file. + private Mock getMockWorkingBeatmap(Texture background, [CanBeNull] byte[] fileBytes = null) + { + var stream = new MemoryStream(fileBytes ?? new byte[1024 * 1024]); + + var mock = new Mock(); + mock.SetupGet(w => w.Beatmap).Returns(beatmap); + mock.SetupGet(w => w.Background).Returns(background); + mock.Setup(w => w.GetStream(It.IsAny())).Returns(stream); + + return mock; + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs new file mode 100644 index 0000000000..5adb91a22e --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckConcurrentObjectsTest.cs @@ -0,0 +1,194 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckConcurrentObjectsTest + { + private CheckConcurrentObjects check; + + [SetUp] + public void Setup() + { + check = new CheckConcurrentObjects(); + } + + [Test] + public void TestCirclesSeparate() + { + assertOk(new List + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 150 } + }); + } + + [Test] + public void TestCirclesConcurrent() + { + assertConcurrentSame(new List + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 100 } + }); + } + + [Test] + public void TestCirclesAlmostConcurrent() + { + assertConcurrentSame(new List + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 101 } + }); + } + + [Test] + public void TestSlidersSeparate() + { + assertOk(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + getSliderMock(startTime: 500, endTime: 900.75d).Object + }); + } + + [Test] + public void TestSlidersConcurrent() + { + assertConcurrentSame(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + getSliderMock(startTime: 300, endTime: 700.75d).Object + }); + } + + [Test] + public void TestSlidersAlmostConcurrent() + { + assertConcurrentSame(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + getSliderMock(startTime: 402, endTime: 902.75d).Object + }); + } + + [Test] + public void TestSliderAndCircleConcurrent() + { + assertConcurrentDifferent(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + new HitCircle { StartTime = 300 } + }); + } + + [Test] + public void TestManyObjectsConcurrent() + { + var hitobjects = new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object, + getSliderMock(startTime: 200, endTime: 500.75d).Object, + new HitCircle { StartTime = 300 } + }; + + var issues = check.Run(getContext(hitobjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(3)); + Assert.That(issues.Where(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent).ToList(), Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame)); + } + + [Test] + public void TestHoldNotesSeparateOnSameColumn() + { + assertOk(new List + { + getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object, + getHoldNoteMock(startTime: 500, endTime: 900.75d, column: 1).Object + }); + } + + [Test] + public void TestHoldNotesConcurrentOnDifferentColumns() + { + assertOk(new List + { + getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object, + getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 2).Object + }); + } + + [Test] + public void TestHoldNotesConcurrentOnSameColumn() + { + assertConcurrentSame(new List + { + getHoldNoteMock(startTime: 100, endTime: 400.75d, column: 1).Object, + getHoldNoteMock(startTime: 300, endTime: 700.75d, column: 1).Object + }); + } + + private Mock getSliderMock(double startTime, double endTime, int repeats = 0) + { + var mock = new Mock(); + mock.SetupGet(s => s.StartTime).Returns(startTime); + mock.As().Setup(r => r.RepeatCount).Returns(repeats); + mock.As().Setup(d => d.EndTime).Returns(endTime); + + return mock; + } + + private Mock getHoldNoteMock(double startTime, double endTime, int column) + { + var mock = new Mock(); + mock.SetupGet(s => s.StartTime).Returns(startTime); + mock.As().Setup(d => d.EndTime).Returns(endTime); + mock.As().Setup(c => c.Column).Returns(column); + + return mock; + } + + private void assertOk(List hitobjects) + { + Assert.That(check.Run(getContext(hitobjects)), Is.Empty); + } + + private void assertConcurrentSame(List hitobjects, int count = 1) + { + var issues = check.Run(getContext(hitobjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentSame)); + } + + private void assertConcurrentDifferent(List hitobjects, int count = 1) + { + var issues = check.Run(getContext(hitobjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckConcurrentObjects.IssueTemplateConcurrentDifferent)); + } + + private BeatmapVerifierContext getContext(List hitobjects) + { + var beatmap = new Beatmap { HitObjects = hitobjects }; + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs b/osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs new file mode 100644 index 0000000000..39a1d76d83 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckFilePresenceTest.cs @@ -0,0 +1,77 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.IO; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckFilePresenceTest + { + private CheckBackgroundPresence check; + private IBeatmap beatmap; + + [SetUp] + public void Setup() + { + check = new CheckBackgroundPresence(); + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata { BackgroundFile = "abc123.jpg" }, + BeatmapSet = new BeatmapSetInfo + { + Files = new List(new[] + { + new BeatmapSetFileInfo + { + Filename = "abc123.jpg", + FileInfo = new FileInfo { Hash = "abcdef" } + } + }) + } + } + }; + } + + [Test] + public void TestBackgroundSetAndInFiles() + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + Assert.That(check.Run(context), Is.Empty); + } + + [Test] + public void TestBackgroundSetAndNotInFiles() + { + beatmap.BeatmapInfo.BeatmapSet.Files.Clear(); + + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckFilePresence.IssueTemplateDoesNotExist); + } + + [Test] + public void TestBackgroundNotSet() + { + beatmap.Metadata.BackgroundFile = null; + + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckFilePresence.IssueTemplateNoneSet); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs new file mode 100644 index 0000000000..882baba8fa --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckUnsnappedObjectsTest.cs @@ -0,0 +1,159 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckUnsnappedObjectsTest + { + private CheckUnsnappedObjects check; + private ControlPointInfo cpi; + + [SetUp] + public void Setup() + { + check = new CheckUnsnappedObjects(); + + cpi = new ControlPointInfo(); + cpi.Add(100, new TimingControlPoint { BeatLength = 100 }); + } + + [Test] + public void TestCircleSnapped() + { + assertOk(new List + { + new HitCircle { StartTime = 100 } + }); + } + + [Test] + public void TestCircleUnsnapped1Ms() + { + assert1Ms(new List + { + new HitCircle { StartTime = 101 } + }); + + assert1Ms(new List + { + new HitCircle { StartTime = 99 } + }); + } + + [Test] + public void TestCircleUnsnapped2Ms() + { + assert2Ms(new List + { + new HitCircle { StartTime = 102 } + }); + + assert2Ms(new List + { + new HitCircle { StartTime = 98 } + }); + } + + [Test] + public void TestSliderSnapped() + { + // Slider ends are naturally < 1 ms unsnapped because of how SV works. + assertOk(new List + { + getSliderMock(startTime: 100, endTime: 400.75d).Object + }); + } + + [Test] + public void TestSliderUnsnapped1Ms() + { + assert1Ms(new List + { + getSliderMock(startTime: 101, endTime: 401.75d).Object + }, count: 2); + + // End is only off by 0.25 ms, hence count 1. + assert1Ms(new List + { + getSliderMock(startTime: 99, endTime: 399.75d).Object + }, count: 1); + } + + [Test] + public void TestSliderUnsnapped2Ms() + { + assert2Ms(new List + { + getSliderMock(startTime: 102, endTime: 402.75d).Object + }, count: 2); + + // Start and end are 2 ms and 1.25 ms off respectively, hence two different issues in one object. + var hitObjects = new List + { + getSliderMock(startTime: 98, endTime: 398.75d).Object + }; + + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.Any(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateSmallUnsnap)); + Assert.That(issues.Any(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateLargeUnsnap)); + } + + private Mock getSliderMock(double startTime, double endTime, int repeats = 0) + { + var mockSlider = new Mock(); + mockSlider.SetupGet(s => s.StartTime).Returns(startTime); + mockSlider.As().Setup(r => r.RepeatCount).Returns(repeats); + mockSlider.As().Setup(d => d.EndTime).Returns(endTime); + + return mockSlider; + } + + private void assertOk(List hitObjects) + { + Assert.That(check.Run(getContext(hitObjects)), Is.Empty); + } + + private void assert1Ms(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateSmallUnsnap)); + } + + private void assert2Ms(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckUnsnappedObjects.IssueTemplateLargeUnsnap)); + } + + private BeatmapVerifierContext getContext(List hitObjects) + { + var beatmap = new Beatmap + { + ControlPointInfo = cpi, + HitObjects = hitObjects + }; + + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectContainerEventBuffer.cs b/osu.Game.Tests/Editing/TestSceneHitObjectContainerEventBuffer.cs new file mode 100644 index 0000000000..592971dbaf --- /dev/null +++ b/osu.Game.Tests/Editing/TestSceneHitObjectContainerEventBuffer.cs @@ -0,0 +1,175 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Compose; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Editing +{ + [HeadlessTest] + public class TestSceneHitObjectContainerEventBuffer : OsuTestScene + { + private readonly TestHitObject testObj = new TestHitObject(); + + private TestPlayfield playfield1; + private TestPlayfield playfield2; + private TestDrawable intermediateDrawable; + private HitObjectUsageEventBuffer eventBuffer; + + private HitObject beganUsage; + private HitObject finishedUsage; + private HitObject transferredUsage; + + [SetUp] + public void Setup() => Schedule(() => + { + reset(); + + if (eventBuffer != null) + { + eventBuffer.HitObjectUsageBegan -= onHitObjectUsageBegan; + eventBuffer.HitObjectUsageFinished -= onHitObjectUsageFinished; + eventBuffer.HitObjectUsageTransferred -= onHitObjectUsageTransferred; + } + + var topPlayfield = new TestPlayfield(); + topPlayfield.AddNested(playfield1 = new TestPlayfield()); + topPlayfield.AddNested(playfield2 = new TestPlayfield()); + + eventBuffer = new HitObjectUsageEventBuffer(topPlayfield); + eventBuffer.HitObjectUsageBegan += onHitObjectUsageBegan; + eventBuffer.HitObjectUsageFinished += onHitObjectUsageFinished; + eventBuffer.HitObjectUsageTransferred += onHitObjectUsageTransferred; + + Children = new Drawable[] + { + topPlayfield, + intermediateDrawable = new TestDrawable(), + }; + }); + + private void onHitObjectUsageBegan(HitObject obj) => beganUsage = obj; + + private void onHitObjectUsageFinished(HitObject obj) => finishedUsage = obj; + + private void onHitObjectUsageTransferred(HitObject obj, DrawableHitObject drawableObj) => transferredUsage = obj; + + [Test] + public void TestUsageBeganAfterAdd() + { + AddStep("add hitobject", () => playfield1.Add(testObj)); + addCheckStep(began: true); + } + + [Test] + public void TestUsageFinishedAfterRemove() + { + AddStep("add hitobject", () => playfield1.Add(testObj)); + addResetStep(); + AddStep("remove hitobject", () => playfield1.Remove(testObj)); + addCheckStep(finished: true); + } + + [Test] + public void TestUsageTransferredWhenMovedBetweenPlayfields() + { + AddStep("add hitobject", () => playfield1.Add(testObj)); + addResetStep(); + AddStep("transfer hitobject to other playfield", () => + { + playfield1.Remove(testObj); + playfield2.Add(testObj); + }); + + addCheckStep(transferred: true); + } + + [Test] + public void TestRemoveImmediatelyAfterUsageBegan() + { + AddStep("add hitobject and schedule removal", () => + { + playfield1.Add(testObj); + intermediateDrawable.Schedule(() => playfield1.Remove(testObj)); + }); + + addCheckStep(began: true, finished: true); + } + + [Test] + public void TestRemoveImmediatelyAfterTransferred() + { + AddStep("add hitobject", () => playfield1.Add(testObj)); + addResetStep(); + AddStep("transfer hitobject to other playfield and schedule removal", () => + { + playfield1.Remove(testObj); + playfield2.Add(testObj); + intermediateDrawable.Schedule(() => playfield2.Remove(testObj)); + }); + + addCheckStep(transferred: true, finished: true); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + eventBuffer.Update(); + } + + private void addResetStep() => AddStep("reset", reset); + + private void reset() + { + beganUsage = null; + finishedUsage = null; + transferredUsage = null; + } + + private void addCheckStep(bool began = false, bool finished = false, bool transferred = false) + => AddAssert($"began = {began}, finished = {finished}, transferred = {transferred}", + () => (beganUsage == testObj) == began && (finishedUsage == testObj) == finished && (transferredUsage == testObj) == transferred); + + private class TestPlayfield : Playfield + { + public TestPlayfield() + { + RegisterPool(1); + } + + public new void AddNested(Playfield playfield) + { + AddInternal(playfield); + base.AddNested(playfield); + } + + protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject) + { + var entry = base.CreateLifetimeEntry(hitObject); + entry.KeepAlive = true; + return entry; + } + } + + private class TestHitObject : HitObject + { + public override string ToString() => "TestHitObject"; + } + + private class TestDrawableHitObject : DrawableHitObject + { + } + + private class TestDrawable : Drawable + { + public new void Schedule(Action action) => base.Schedule(action); + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs new file mode 100644 index 0000000000..da0d57f9d1 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs @@ -0,0 +1,163 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneDrawableHitObject : OsuTestScene + { + [Test] + public void TestEntryLifetime() + { + TestDrawableHitObject dho = null; + var initialHitObject = new HitObject + { + StartTime = 1000 + }; + var entry = new TestLifetimeEntry(new HitObject + { + StartTime = 2000 + }); + + AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject(initialHitObject)); + + AddAssert("Correct initial lifetime", () => dho.LifetimeStart == initialHitObject.StartTime - TestDrawableHitObject.INITIAL_LIFETIME_OFFSET); + + AddStep("Apply entry", () => dho.Apply(entry)); + + AddAssert("Correct initial lifetime", () => dho.LifetimeStart == entry.HitObject.StartTime - TestLifetimeEntry.INITIAL_LIFETIME_OFFSET); + + AddStep("Set lifetime", () => dho.LifetimeEnd = 3000); + AddAssert("Entry lifetime is updated", () => entry.LifetimeEnd == 3000); + } + + [Test] + public void TestKeepAlive() + { + TestDrawableHitObject dho = null; + TestLifetimeEntry entry = null; + AddStep("Create DHO", () => + { + dho = new TestDrawableHitObject(null); + dho.Apply(entry = new TestLifetimeEntry(new HitObject())); + Child = dho; + }); + + AddStep("KeepAlive = true", () => + { + entry.LifetimeStart = 0; + entry.LifetimeEnd = 1000; + entry.KeepAlive = true; + }); + AddAssert("Lifetime is overriden", () => entry.LifetimeStart == double.MinValue && entry.LifetimeEnd == double.MaxValue); + + AddStep("Set LifetimeStart", () => dho.LifetimeStart = 500); + AddStep("KeepAlive = false", () => entry.KeepAlive = false); + AddAssert("Lifetime is correct", () => entry.LifetimeStart == 500 && entry.LifetimeEnd == 1000); + + AddStep("Set LifetimeStart while KeepAlive", () => + { + entry.KeepAlive = true; + dho.LifetimeStart = double.MinValue; + entry.KeepAlive = false; + }); + AddAssert("Lifetime is changed", () => entry.LifetimeStart == double.MinValue && entry.LifetimeEnd == 1000); + } + + [Test] + public void TestLifetimeUpdatedOnDefaultApplied() + { + TestLifetimeEntry entry = null; + AddStep("Create entry", () => entry = new TestLifetimeEntry(new HitObject()) { LifetimeStart = 1 }); + AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty())); + AddAssert("Lifetime is updated", () => entry.LifetimeStart == -TestLifetimeEntry.INITIAL_LIFETIME_OFFSET); + + TestDrawableHitObject dho = null; + AddStep("Create DHO", () => + { + dho = new TestDrawableHitObject(null); + dho.Apply(entry); + Child = dho; + dho.SetLifetimeStartOnApply = true; + }); + AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty())); + AddAssert("Lifetime is correct", () => dho.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY && entry.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY); + } + + [Test] + public void TestDrawableLifetimeUpdateOnEntryLifetimeChange() + { + TestDrawableHitObject dho = null; + TestLifetimeEntry entry = null; + AddStep("Create DHO", () => + { + dho = new TestDrawableHitObject(null); + dho.Apply(entry = new TestLifetimeEntry(new HitObject())); + Child = dho; + }); + + AddStep("Set entry lifetime", () => + { + entry.LifetimeStart = 777; + entry.LifetimeEnd = 888; + }); + AddAssert("Drawable lifetime is updated", () => dho.LifetimeStart == 777 && dho.LifetimeEnd == 888); + + AddStep("KeepAlive = true", () => entry.KeepAlive = true); + AddAssert("Drawable lifetime is updated", () => dho.LifetimeStart == double.MinValue && dho.LifetimeEnd == double.MaxValue); + + AddStep("Modify start time", () => entry.HitObject.StartTime = 100); + AddAssert("Drawable lifetime is correct", () => dho.LifetimeStart == double.MinValue); + + AddStep("Set LifetimeStart", () => dho.LifetimeStart = 666); + AddAssert("Lifetime change is blocked", () => dho.LifetimeStart == double.MinValue); + + AddStep("Set LifetimeEnd", () => dho.LifetimeEnd = 999); + AddAssert("Lifetime change is blocked", () => dho.LifetimeEnd == double.MaxValue); + + AddStep("KeepAlive = false", () => entry.KeepAlive = false); + AddAssert("Drawable lifetime is restored", () => dho.LifetimeStart == 666 && dho.LifetimeEnd == 999); + } + + private class TestDrawableHitObject : DrawableHitObject + { + public const double INITIAL_LIFETIME_OFFSET = 100; + public const double LIFETIME_ON_APPLY = 222; + protected override double InitialLifetimeOffset => INITIAL_LIFETIME_OFFSET; + + public bool SetLifetimeStartOnApply; + + public TestDrawableHitObject(HitObject hitObject) + : base(hitObject) + { + } + + protected override void OnApply() + { + base.OnApply(); + + if (SetLifetimeStartOnApply) + LifetimeStart = LIFETIME_ON_APPLY; + } + } + + private class TestLifetimeEntry : HitObjectLifetimeEntry + { + public const double INITIAL_LIFETIME_OFFSET = 200; + protected override double InitialLifetimeOffset => INITIAL_LIFETIME_OFFSET; + + public TestLifetimeEntry(HitObject hitObject) + : base(hitObject) + { + } + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs b/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs deleted file mode 100644 index 891537c4ad..0000000000 --- a/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs +++ /dev/null @@ -1,32 +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 NUnit.Framework; -using osu.Framework.Testing; -using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Play; -using osu.Game.Tests.Visual; - -namespace osu.Game.Tests.Gameplay -{ - [HeadlessTest] - public class TestSceneGameplayClockContainer : OsuTestScene - { - [Test] - public void TestStartThenElapsedTime() - { - GameplayClockContainer gcc = null; - - AddStep("create container", () => - { - var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - working.LoadTrack(); - - Add(gcc = new GameplayClockContainer(working, 0)); - }); - - AddStep("start track", () => gcc.Start()); - AddUntilStep("elapsed greater than zero", () => gcc.GameplayClock.ElapsedFrameTime > 0); - } - } -} diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index 3ded3009bd..883791c35c 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -121,7 +121,7 @@ namespace osu.Game.Tests.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) { diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs index 64eaafbe75..fc420e22a1 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs @@ -14,7 +14,7 @@ namespace osu.Game.Tests.Gameplay public class TestSceneHitObjectSamples : HitObjectSampleTest { protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); - protected override IResourceStore Resources => TestResources.GetStore(); + protected override IResourceStore RulesetResources => TestResources.GetStore(); /// /// Tests that a hitobject which provides no custom sample set retrieves samples from the user skin. diff --git a/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs new file mode 100644 index 0000000000..935bc07733 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneMasterGameplayClockContainer.cs @@ -0,0 +1,58 @@ +// 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.Testing; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneMasterGameplayClockContainer : OsuTestScene + { + [Test] + public void TestStartThenElapsedTime() + { + GameplayClockContainer gcc = null; + + AddStep("create container", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.LoadTrack(); + + Add(gcc = new MasterGameplayClockContainer(working, 0)); + }); + + AddStep("start clock", () => gcc.Start()); + AddUntilStep("elapsed greater than zero", () => gcc.GameplayClock.ElapsedFrameTime > 0); + } + + [Test] + public void TestElapseThenReset() + { + GameplayClockContainer gcc = null; + + AddStep("create container", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.LoadTrack(); + + Add(gcc = new MasterGameplayClockContainer(working, 0)); + }); + + AddStep("start clock", () => gcc.Start()); + AddUntilStep("current time greater 2000", () => gcc.GameplayClock.CurrentTime > 2000); + + double timeAtReset = 0; + AddStep("reset clock", () => + { + timeAtReset = gcc.GameplayClock.CurrentTime; + gcc.Reset(); + }); + + AddAssert("current time < time at reset", () => gcc.GameplayClock.CurrentTime < timeAtReset); + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneProxyContainer.cs b/osu.Game.Tests/Gameplay/TestSceneProxyContainer.cs new file mode 100644 index 0000000000..1264d575a4 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneProxyContainer.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneProxyContainer : OsuTestScene + { + private HitObjectContainer hitObjectContainer; + private ProxyContainer proxyContainer; + private readonly ManualClock clock = new ManualClock(); + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = new Container + { + Children = new Drawable[] + { + hitObjectContainer = new HitObjectContainer(), + proxyContainer = new ProxyContainer() + }, + Clock = new FramedClock(clock) + }; + clock.CurrentTime = 0; + }); + + [Test] + public void TestProxyLifetimeManagement() + { + AddStep("Add proxy drawables", () => + { + addProxy(new TestDrawableHitObject(1000)); + addProxy(new TestDrawableHitObject(3000)); + addProxy(new TestDrawableHitObject(5000)); + }); + + AddStep("time = 1000", () => clock.CurrentTime = 1000); + AddAssert("One proxy is alive", () => proxyContainer.AliveChildren.Count == 1); + AddStep("time = 5000", () => clock.CurrentTime = 5000); + AddAssert("One proxy is alive", () => proxyContainer.AliveChildren.Count == 1); + AddStep("time = 6000", () => clock.CurrentTime = 6000); + AddAssert("No proxy is alive", () => proxyContainer.AliveChildren.Count == 0); + } + + private void addProxy(DrawableHitObject drawableHitObject) + { + hitObjectContainer.Add(drawableHitObject); + proxyContainer.AddProxy(drawableHitObject); + } + + private class ProxyContainer : LifetimeManagementContainer + { + public IReadOnlyList AliveChildren => AliveInternalChildren; + + public void AddProxy(Drawable d) => AddInternal(d.CreateProxy()); + } + + private class TestDrawableHitObject : DrawableHitObject + { + protected override double InitialLifetimeOffset => 100; + + public TestDrawableHitObject(double startTime) + : base(new HitObject { StartTime = startTime }) + { + } + + protected override void UpdateInitialTransforms() + { + LifetimeEnd = LifetimeStart + 500; + } + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index 7a0dd5b719..aed28f5f84 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -13,12 +13,14 @@ using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.IO; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osu.Game.Skinning; using osu.Game.Storyboards; @@ -35,7 +37,7 @@ namespace osu.Game.Tests.Gameplay public void TestRetrieveTopLevelSample() { ISkin skin = null; - Sample channel = null; + ISample channel = null; AddStep("create skin", () => skin = new TestSkin("test-sample", this)); AddStep("retrieve sample", () => channel = skin.GetSample(new SampleInfo("test-sample"))); @@ -47,7 +49,7 @@ namespace osu.Game.Tests.Gameplay public void TestRetrieveSampleInSubFolder() { ISkin skin = null; - Sample channel = null; + ISample channel = null; AddStep("create skin", () => skin = new TestSkin("folder/test-sample", this)); AddStep("retrieve sample", () => channel = skin.GetSample(new SampleInfo("folder/test-sample"))); @@ -66,17 +68,47 @@ namespace osu.Game.Tests.Gameplay var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); working.LoadTrack(); - Add(gameplayContainer = new GameplayClockContainer(working, 0)); - - gameplayContainer.Add(sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) + Add(gameplayContainer = new MasterGameplayClockContainer(working, 0) { - Clock = gameplayContainer.GameplayClock + IsPaused = { Value = true }, + Child = new FrameStabilityContainer + { + Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) + } + }); + }); + + AddStep("reset clock", () => gameplayContainer.Start()); + + AddUntilStep("sample played", () => sample.RequestedPlaying); + AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue); + } + + [Test] + public void TestSampleHasLifetimeEndWithInitialClockTime() + { + GameplayClockContainer gameplayContainer = null; + DrawableStoryboardSample sample = null; + + AddStep("create container", () => + { + var working = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + working.LoadTrack(); + + Add(gameplayContainer = new MasterGameplayClockContainer(working, 1000, true) + { + IsPaused = { Value = true }, + Child = new FrameStabilityContainer + { + Child = sample = new DrawableStoryboardSample(new StoryboardSampleInfo(string.Empty, 0, 1)) + } }); }); AddStep("start time", () => gameplayContainer.Start()); - AddUntilStep("sample playback succeeded", () => sample.LifetimeEnd < double.MaxValue); + AddUntilStep("sample not played", () => !sample.RequestedPlaying); + AddUntilStep("sample has lifetime end", () => sample.LifetimeEnd < double.MaxValue); } [TestCase(typeof(OsuModDoubleTime), 1.5)] @@ -90,6 +122,7 @@ namespace osu.Game.Tests.Gameplay public void TestSamplePlaybackWithRateMods(Type expectedMod, double expectedRate) { GameplayClockContainer gameplayContainer = null; + StoryboardSampleInfo sampleInfo = null; TestDrawableStoryboardSample sample = null; Mod testedMod = Activator.CreateInstance(expectedMod) as Mod; @@ -101,7 +134,7 @@ namespace osu.Game.Tests.Gameplay break; case ModTimeRamp m: - m.InitialRate.Value = m.FinalRate.Value = expectedRate; + m.FinalRate.Value = m.InitialRate.Value = expectedRate; break; } @@ -112,12 +145,12 @@ namespace osu.Game.Tests.Gameplay var beatmapSkinSourceContainer = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin); - Add(gameplayContainer = new GameplayClockContainer(Beatmap.Value, 0) + Add(gameplayContainer = new MasterGameplayClockContainer(Beatmap.Value, 0) { Child = beatmapSkinSourceContainer }); - beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1)) + beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(sampleInfo = new StoryboardSampleInfo("test-sample", 1, 1)) { Clock = gameplayContainer.GameplayClock }); @@ -125,7 +158,10 @@ namespace osu.Game.Tests.Gameplay AddStep("start", () => gameplayContainer.Start()); - AddAssert("sample playback rate matches mod rates", () => sample.ChildrenOfType().First().AggregateFrequency.Value == expectedRate); + AddAssert("sample playback rate matches mod rates", () => + testedMod != null && Precision.AlmostEquals( + sample.ChildrenOfType().First().AggregateFrequency.Value, + ((IApplicableToRate)testedMod).ApplyToRate(sampleInfo.StartTime))); } private class TestSkin : LegacySkin @@ -183,6 +219,7 @@ namespace osu.Game.Tests.Gameplay public AudioManager AudioManager => Audio; public IResourceStore Files => null; + public new IResourceStore Resources => base.Resources; public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => null; #endregion diff --git a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs index b90382488f..27cece42e8 100644 --- a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs +++ b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs @@ -88,7 +88,7 @@ namespace osu.Game.Tests.Input => AddStep($"make window {mode}", () => frameworkConfigManager.GetBindable(FrameworkSetting.WindowMode).Value = mode); private void setGameSideModeTo(OsuConfineMouseMode mode) - => AddStep($"set {mode} game-side", () => Game.LocalConfig.Set(OsuSetting.ConfineMouseMode, mode)); + => AddStep($"set {mode} game-side", () => Game.LocalConfig.SetValue(OsuSetting.ConfineMouseMode, mode)); private void setLocalUserPlayingTo(bool playing) => AddStep($"local user {(playing ? "playing" : "not playing")}", () => Game.LocalUserPlaying.Value = playing); diff --git a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs new file mode 100644 index 0000000000..7a5789f01a --- /dev/null +++ b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs @@ -0,0 +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 NUnit.Framework; +using osu.Game.Online.API; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Tests.Mods +{ + [TestFixture] + public class ModSettingsEqualityComparison + { + [Test] + public void Test() + { + var mod1 = new OsuModDoubleTime { SpeedChange = { Value = 1.25 } }; + var mod2 = new OsuModDoubleTime { SpeedChange = { Value = 1.26 } }; + var mod3 = new OsuModDoubleTime { SpeedChange = { Value = 1.26 } }; + var apiMod1 = new APIMod(mod1); + var apiMod2 = new APIMod(mod2); + var apiMod3 = new APIMod(mod3); + + Assert.That(mod1, Is.Not.EqualTo(mod2)); + Assert.That(apiMod1, Is.Not.EqualTo(apiMod2)); + + Assert.That(mod2, Is.EqualTo(mod2)); + Assert.That(apiMod2, Is.EqualTo(apiMod2)); + + Assert.That(mod2, Is.EqualTo(mod3)); + Assert.That(apiMod2, Is.EqualTo(apiMod3)); + + Assert.That(mod3, Is.EqualTo(mod2)); + Assert.That(apiMod3, Is.EqualTo(apiMod2)); + } + } +} diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 7dcaabca3d..7384471c41 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -146,7 +146,7 @@ namespace osu.Game.Tests.Mods if (isValid) Assert.IsNull(invalid); else - Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } public abstract class CustomMod1 : Mod diff --git a/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs b/osu.Game.Tests/Mods/SettingsSourceAttributeTest.cs new file mode 100644 index 0000000000..dd105787fa --- /dev/null +++ b/osu.Game.Tests/Mods/SettingsSourceAttributeTest.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 NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Tests.Mods +{ + [TestFixture] + public class SettingsSourceAttributeTest + { + [Test] + public void TestOrdering() + { + var objectWithSettings = new ClassWithSettings(); + + var orderedSettings = objectWithSettings.GetOrderedSettingsSourceProperties().ToArray(); + + Assert.That(orderedSettings, Has.Length.EqualTo(4)); + + Assert.That(orderedSettings[0].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.FirstSetting))); + Assert.That(orderedSettings[1].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.SecondSetting))); + Assert.That(orderedSettings[2].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.ThirdSetting))); + Assert.That(orderedSettings[3].Item2.Name, Is.EqualTo(nameof(ClassWithSettings.UnorderedSetting))); + } + + [Test] + public void TestCustomControl() + { + var objectWithCustomSettingControl = new ClassWithCustomSettingControl(); + var settings = objectWithCustomSettingControl.CreateSettingsControls().ToArray(); + + Assert.That(settings, Has.Length.EqualTo(1)); + Assert.That(settings[0], Is.TypeOf()); + } + + private class ClassWithSettings + { + [SettingSource("Unordered setting", "Should be last")] + public BindableFloat UnorderedSetting { get; set; } = new BindableFloat(); + + [SettingSource("Second setting", "Another description", 2)] + public BindableBool SecondSetting { get; set; } = new BindableBool(); + + [SettingSource("First setting", "A description", 1)] + public BindableDouble FirstSetting { get; set; } = new BindableDouble(); + + [SettingSource("Third setting", "Yet another description", 3)] + public BindableInt ThirdSetting { get; set; } = new BindableInt(); + } + + private class ClassWithCustomSettingControl + { + [SettingSource("Custom setting", "Should be a custom control", SettingControlType = typeof(CustomSettingsControl))] + public BindableInt UnorderedSetting { get; set; } = new BindableInt(); + } + + private class CustomSettingsControl : SettingsItem + { + protected override Drawable CreateControl() => new CustomControl(); + + private class CustomControl : Drawable, IHasCurrentValue + { + public Bindable Current { get; set; } = new Bindable(); + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs b/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs new file mode 100644 index 0000000000..08cd80dcfa --- /dev/null +++ b/osu.Game.Tests/NonVisual/ClosestBeatDivisorTest.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Tests.NonVisual +{ + public class ClosestBeatDivisorTest + { + [Test] + public void TestExactDivisors() + { + var cpi = new ControlPointInfo(); + cpi.Add(-1000, new TimingControlPoint { BeatLength = 1000 }); + + double[] divisors = { 3, 1, 16, 12, 8, 6, 4, 3, 2, 1 }; + + assertClosestDivisors(divisors, divisors, cpi); + } + + [Test] + public void TestExactDivisorWithTempoChanges() + { + int offset = 0; + int[] beatLengths = { 1000, 200, 100, 50 }; + + var cpi = new ControlPointInfo(); + + foreach (int beatLength in beatLengths) + { + cpi.Add(offset, new TimingControlPoint { BeatLength = beatLength }); + offset += beatLength * 2; + } + + double[] divisors = { 3, 1, 16, 12, 8, 6, 4, 3 }; + + assertClosestDivisors(divisors, divisors, cpi); + } + + [Test] + public void TestExactDivisorsHighBPMStream() + { + var cpi = new ControlPointInfo(); + cpi.Add(-50, new TimingControlPoint { BeatLength = 50 }); // 1200 BPM 1/4 (limit testing) + + // A 1/4 stream should land on 1/1, 1/2 and 1/4 divisors. + double[] divisors = { 4, 4, 4, 4, 4, 4, 4, 4 }; + double[] closestDivisors = { 4, 2, 4, 1, 4, 2, 4, 1 }; + + assertClosestDivisors(divisors, closestDivisors, cpi, step: 1 / 4d); + } + + [Test] + public void TestApproximateDivisors() + { + var cpi = new ControlPointInfo(); + cpi.Add(-1000, new TimingControlPoint { BeatLength = 1000 }); + + double[] divisors = { 3.03d, 0.97d, 14, 13, 7.94d, 6.08d, 3.93d, 2.96d, 2.02d, 64 }; + double[] closestDivisors = { 3, 1, 16, 12, 8, 6, 4, 3, 2, 1 }; + + assertClosestDivisors(divisors, closestDivisors, cpi); + } + + private void assertClosestDivisors(IReadOnlyList divisors, IReadOnlyList closestDivisors, ControlPointInfo cpi, double step = 1) + { + List hitobjects = new List(); + double offset = cpi.TimingPoints[0].Time; + + for (int i = 0; i < divisors.Count; ++i) + { + double beatLength = cpi.TimingPointAt(offset).BeatLength; + hitobjects.Add(new HitObject { StartTime = offset + beatLength / divisors[i] }); + offset += beatLength * step; + } + + var beatmap = new Beatmap + { + HitObjects = hitobjects, + ControlPointInfo = cpi + }; + + for (int i = 0; i < divisors.Count; ++i) + Assert.AreEqual(closestDivisors[i], beatmap.ControlPointInfo.GetClosestBeatDivisor(beatmap.HitObjects[i].StartTime), $"at index {i}"); + } + } +} diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index 045246e5ed..a763544c37 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -47,7 +47,7 @@ namespace osu.Game.Tests.NonVisual using (var host = new CustomTestHeadlessGameHost()) { using (var storageConfig = new StorageConfigManager(host.InitialStorage)) - storageConfig.Set(StorageConfig.FullPath, customPath); + storageConfig.SetValue(StorageConfig.FullPath, customPath); try { @@ -73,7 +73,7 @@ namespace osu.Game.Tests.NonVisual using (var host = new CustomTestHeadlessGameHost()) { using (var storageConfig = new StorageConfigManager(host.InitialStorage)) - storageConfig.Set(StorageConfig.FullPath, customPath); + storageConfig.SetValue(StorageConfig.FullPath, customPath); try { diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 1c0bfd56dd..16c1004f37 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -144,6 +144,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => nameof(ModA); public override string Acronym => nameof(ModA); + public override string Description => string.Empty; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModIncompatibleWithA), typeof(ModIncompatibleWithAAndB) }; @@ -152,6 +153,7 @@ namespace osu.Game.Tests.NonVisual private class ModB : Mod { public override string Name => nameof(ModB); + public override string Description => string.Empty; public override string Acronym => nameof(ModB); public override double ScoreMultiplier => 1; @@ -162,6 +164,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => nameof(ModC); public override string Acronym => nameof(ModC); + public override string Description => string.Empty; public override double ScoreMultiplier => 1; } @@ -169,6 +172,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => $"Incompatible With {nameof(ModA)}"; public override string Acronym => $"Incompatible With {nameof(ModA)}"; + public override string Description => string.Empty; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModA) }; @@ -187,6 +191,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => $"Incompatible With {nameof(ModA)} and {nameof(ModB)}"; public override string Acronym => $"Incompatible With {nameof(ModA)} and {nameof(ModB)}"; + public override string Description => string.Empty; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModA), typeof(ModB) }; diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 24a0a662ba..8ff2743b6a 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -4,8 +4,10 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Filter; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; +using osu.Game.Screens.Select.Filter; namespace osu.Game.Tests.NonVisual.Filtering { @@ -214,5 +216,31 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(filtered, carouselItem.Filtered.Value); } + + [Test] + public void TestCustomRulesetCriteria([Values(null, true, false)] bool? matchCustomCriteria) + { + var beatmap = getExampleBeatmap(); + + var customCriteria = matchCustomCriteria is bool match ? new CustomCriteria(match) : null; + var criteria = new FilterCriteria { RulesetCriteria = customCriteria }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(matchCustomCriteria == false, carouselItem.Filtered.Value); + } + + private class CustomCriteria : IRulesetFilterCriteria + { + private readonly bool match; + + public CustomCriteria(bool shouldMatch) + { + match = shouldMatch; + } + + public bool Matches(BeatmapInfo beatmap) => match; + public bool TryParseCustomKeywordCriteria(string key, Operator op, string value) => false; + } } } diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index d15682b1eb..49389e67aa 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -4,7 +4,9 @@ using System; using NUnit.Framework; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Filter; using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; namespace osu.Game.Tests.NonVisual.Filtering { @@ -194,5 +196,63 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(1, filterCriteria.SearchTerms.Length); Assert.AreEqual("double\"quote", filterCriteria.Artist.SearchTerm); } + + [Test] + public void TestOperatorParsing() + { + const string query = "artist=>=bad")] + [TestCase("divisor true; + + public bool TryParseCustomKeywordCriteria(string key, Operator op, string value) + { + if (key == "custom" && op == Operator.Equal) + { + CustomValue = value; + return true; + } + + return false; + } + } } } diff --git a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs new file mode 100644 index 0000000000..df095ddee3 --- /dev/null +++ b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Globalization; +using NUnit.Framework; +using osu.Game.Utils; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class FormatUtilsTest + { + [TestCase(0, "0.00%")] + [TestCase(0.01, "1.00%")] + [TestCase(0.9899, "98.99%")] + [TestCase(0.989999, "98.99%")] + [TestCase(0.99, "99.00%")] + [TestCase(0.9999, "99.99%")] + [TestCase(0.999999, "99.99%")] + [TestCase(1, "100.00%")] + public void TestAccuracyFormatting(double input, string expectedOutput) + { + Assert.AreEqual(expectedOutput, input.FormatAccuracy(CultureInfo.InvariantCulture)); + } + } +} diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs index 92a60663de..407dec936b 100644 --- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs +++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Game.Replays; using osu.Game.Rulesets.Replays; @@ -20,27 +21,14 @@ namespace osu.Game.Tests.NonVisual { handler = new TestInputHandler(replay = new Replay { - Frames = new List - { - new TestReplayFrame(0), - new TestReplayFrame(1000), - new TestReplayFrame(2000), - new TestReplayFrame(3000, true), - new TestReplayFrame(4000, true), - new TestReplayFrame(5000, true), - new TestReplayFrame(7000, true), - new TestReplayFrame(8000), - } + HasReceivedAllFrames = false }); } [Test] public void TestNormalPlayback() { - Assert.IsNull(handler.CurrentFrame); - - confirmCurrentFrame(null); - confirmNextFrame(0); + setReplayFrames(); setTime(0, 0); confirmCurrentFrame(0); @@ -107,6 +95,8 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestIntroTime() { + setReplayFrames(); + setTime(-1000, -1000); confirmCurrentFrame(null); confirmNextFrame(0); @@ -123,6 +113,8 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestBasicRewind() { + setReplayFrames(); + setTime(2800, 0); setTime(2800, 1000); setTime(2800, 2000); @@ -133,34 +125,35 @@ namespace osu.Game.Tests.NonVisual // pivot without crossing a frame boundary setTime(2700, 2700); confirmCurrentFrame(2); - confirmNextFrame(1); + confirmNextFrame(3); - // cross current frame boundary; should not yet update frame - setTime(1980, 1980); + // cross current frame boundary + setTime(1980, 2000); confirmCurrentFrame(2); - confirmNextFrame(1); + confirmNextFrame(3); setTime(1200, 1200); - confirmCurrentFrame(2); - confirmNextFrame(1); + confirmCurrentFrame(1); + confirmNextFrame(2); // ensure each frame plays out until start setTime(-500, 1000); confirmCurrentFrame(1); - confirmNextFrame(0); + confirmNextFrame(2); setTime(-500, 0); confirmCurrentFrame(0); - confirmNextFrame(null); + confirmNextFrame(1); setTime(-500, -500); - confirmCurrentFrame(0); - confirmNextFrame(null); + confirmCurrentFrame(null); + confirmNextFrame(0); } [Test] public void TestRewindInsideImportantSection() { + setReplayFrames(); fastForwardToPoint(3000); setTime(4000, 4000); @@ -168,12 +161,12 @@ namespace osu.Game.Tests.NonVisual confirmNextFrame(5); setTime(3500, null); - confirmCurrentFrame(4); - confirmNextFrame(3); + confirmCurrentFrame(3); + confirmNextFrame(4); setTime(3000, 3000); confirmCurrentFrame(3); - confirmNextFrame(2); + confirmNextFrame(4); setTime(3500, null); confirmCurrentFrame(3); @@ -187,46 +180,175 @@ namespace osu.Game.Tests.NonVisual confirmCurrentFrame(4); confirmNextFrame(5); - setTime(4000, null); + setTime(4000, 4000); confirmCurrentFrame(4); confirmNextFrame(5); setTime(3500, null); - confirmCurrentFrame(4); - confirmNextFrame(3); + confirmCurrentFrame(3); + confirmNextFrame(4); setTime(3000, 3000); confirmCurrentFrame(3); - confirmNextFrame(2); + confirmNextFrame(4); } [Test] public void TestRewindOutOfImportantSection() { + setReplayFrames(); fastForwardToPoint(3500); confirmCurrentFrame(3); confirmNextFrame(4); setTime(3200, null); - // next frame doesn't change even though direction reversed, because of important section. confirmCurrentFrame(3); confirmNextFrame(4); - setTime(3000, null); + setTime(3000, 3000); confirmCurrentFrame(3); confirmNextFrame(4); setTime(2800, 2800); - confirmCurrentFrame(3); - confirmNextFrame(2); + confirmCurrentFrame(2); + confirmNextFrame(3); + } + + [Test] + public void TestReplayStreaming() + { + // no frames are arrived yet + setTime(0, null); + setTime(1000, null); + Assert.IsTrue(handler.WaitingForFrame, "Should be waiting for the first frame"); + + replay.Frames.Add(new TestReplayFrame(0)); + replay.Frames.Add(new TestReplayFrame(1000)); + + // should always play from beginning + setTime(1000, 0); + confirmCurrentFrame(0); + Assert.IsFalse(handler.WaitingForFrame, "Should not be waiting yet"); + setTime(1000, 1000); + confirmCurrentFrame(1); + confirmNextFrame(null); + Assert.IsTrue(handler.WaitingForFrame, "Should be waiting"); + + // cannot seek beyond the last frame + setTime(1500, null); + confirmCurrentFrame(1); + + setTime(-100, 0); + confirmCurrentFrame(0); + + // can seek to the point before the first frame, however + setTime(-100, -100); + confirmCurrentFrame(null); + confirmNextFrame(0); + + fastForwardToPoint(1000); + setTime(3000, null); + replay.Frames.Add(new TestReplayFrame(2000)); + confirmCurrentFrame(1); + setTime(1000, 1000); + setTime(3000, 2000); + } + + [Test] + public void TestMultipleFramesSameTime() + { + replay.Frames.Add(new TestReplayFrame(0)); + replay.Frames.Add(new TestReplayFrame(0)); + replay.Frames.Add(new TestReplayFrame(1000)); + replay.Frames.Add(new TestReplayFrame(1000)); + replay.Frames.Add(new TestReplayFrame(2000)); + + // forward direction is prioritized when multiple frames have the same time. + setTime(0, 0); + setTime(0, 0); + + setTime(2000, 1000); + setTime(2000, 1000); + + setTime(1000, 1000); + setTime(1000, 1000); + setTime(-100, 1000); + setTime(-100, 0); + setTime(-100, 0); + setTime(-100, -100); + } + + [Test] + public void TestReplayFramesSortStability() + { + const double repeating_time = 5000; + + // add a collection of frames in shuffled order time-wise; each frame also stores its original index to check stability later. + // data is hand-picked and breaks if the unstable List.Sort() is used. + // in theory this can still return a false-positive with another unstable algorithm if extremely unlucky, + // but there is no conceivable fool-proof way to prevent that anyways. + replay.Frames.AddRange(new[] + { + repeating_time, + 0, + 3000, + repeating_time, + repeating_time, + 6000, + 9000, + repeating_time, + repeating_time, + 1000, + 11000, + 21000, + 4000, + repeating_time, + repeating_time, + 8000, + 2000, + 7000, + repeating_time, + repeating_time, + 10000 + }.Select((time, index) => new TestReplayFrame(time, true, index))); + + replay.HasReceivedAllFrames = true; + + // create a new handler with the replay for the sort to be performed. + handler = new TestInputHandler(replay); + + // ensure sort stability by checking that the frames with time == repeating_time are sorted in ascending frame index order themselves. + var repeatingTimeFramesData = replay.Frames + .Cast() + .Where(f => f.Time == repeating_time) + .Select(f => f.FrameIndex); + + Assert.That(repeatingTimeFramesData, Is.Ordered.Ascending); + } + + private void setReplayFrames() + { + replay.Frames = new List + { + new TestReplayFrame(0), + new TestReplayFrame(1000), + new TestReplayFrame(2000), + new TestReplayFrame(3000, true), + new TestReplayFrame(4000, true), + new TestReplayFrame(5000, true), + new TestReplayFrame(7000, true), + new TestReplayFrame(8000), + }; + replay.HasReceivedAllFrames = true; } private void fastForwardToPoint(double destination) { for (int i = 0; i < 1000; i++) { - if (handler.SetFrameFromTime(destination) == null) + var time = handler.SetFrameFromTime(destination); + if (time == null || time == destination) return; } @@ -235,43 +357,29 @@ namespace osu.Game.Tests.NonVisual private void setTime(double set, double? expect) { - Assert.AreEqual(expect, handler.SetFrameFromTime(set)); + Assert.AreEqual(expect, handler.SetFrameFromTime(set), "Unexpected return value"); } private void confirmCurrentFrame(int? frame) { - if (frame.HasValue) - { - Assert.IsNotNull(handler.CurrentFrame); - Assert.AreEqual(replay.Frames[frame.Value].Time, handler.CurrentFrame.Time); - } - else - { - Assert.IsNull(handler.CurrentFrame); - } + Assert.AreEqual(frame is int x ? replay.Frames[x].Time : (double?)null, handler.CurrentFrame?.Time, "Unexpected current frame"); } private void confirmNextFrame(int? frame) { - if (frame.HasValue) - { - Assert.IsNotNull(handler.NextFrame); - Assert.AreEqual(replay.Frames[frame.Value].Time, handler.NextFrame.Time); - } - else - { - Assert.IsNull(handler.NextFrame); - } + Assert.AreEqual(frame is int x ? replay.Frames[x].Time : (double?)null, handler.NextFrame?.Time, "Unexpected next frame"); } private class TestReplayFrame : ReplayFrame { public readonly bool IsImportant; + public readonly int FrameIndex; - public TestReplayFrame(double time, bool isImportant = false) + public TestReplayFrame(double time, bool isImportant = false, int frameIndex = 0) : base(time) { IsImportant = isImportant; + FrameIndex = frameIndex; } } diff --git a/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs b/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs deleted file mode 100644 index d5ac38008e..0000000000 --- a/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs +++ /dev/null @@ -1,115 +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 NUnit.Framework; -using osu.Game.Rulesets.Difficulty.Utils; - -namespace osu.Game.Tests.NonVisual -{ - [TestFixture] - public class LimitedCapacityStackTest - { - private const int capacity = 3; - - private LimitedCapacityStack stack; - - [SetUp] - public void Setup() - { - stack = new LimitedCapacityStack(capacity); - } - - [Test] - public void TestEmptyStack() - { - Assert.AreEqual(0, stack.Count); - - Assert.Throws(() => - { - int unused = stack[0]; - }); - - int count = 0; - foreach (var unused in stack) - count++; - - Assert.AreEqual(0, count); - } - - [TestCase(1)] - [TestCase(2)] - [TestCase(3)] - public void TestInRangeElements(int count) - { - // e.g. 0 -> 1 -> 2 - for (int i = 0; i < count; i++) - stack.Push(i); - - Assert.AreEqual(count, stack.Count); - - // e.g. 2 -> 1 -> 0 (reverse order) - for (int i = 0; i < stack.Count; i++) - Assert.AreEqual(count - 1 - i, stack[i]); - - // e.g. indices 3, 4, 5, 6 (out of range) - for (int i = stack.Count; i < stack.Count + capacity; i++) - { - Assert.Throws(() => - { - int unused = stack[i]; - }); - } - } - - [TestCase(4)] - [TestCase(5)] - [TestCase(6)] - public void TestOverflowElements(int count) - { - // e.g. 0 -> 1 -> 2 -> 3 - for (int i = 0; i < count; i++) - stack.Push(i); - - Assert.AreEqual(capacity, stack.Count); - - // e.g. 3 -> 2 -> 1 (reverse order) - for (int i = 0; i < stack.Count; i++) - Assert.AreEqual(count - 1 - i, stack[i]); - - // e.g. indices 3, 4, 5, 6 (out of range) - for (int i = stack.Count; i < stack.Count + capacity; i++) - { - Assert.Throws(() => - { - int unused = stack[i]; - }); - } - } - - [TestCase(1)] - [TestCase(2)] - [TestCase(3)] - [TestCase(4)] - [TestCase(5)] - [TestCase(6)] - public void TestEnumerator(int count) - { - // e.g. 0 -> 1 -> 2 -> 3 - for (int i = 0; i < count; i++) - stack.Push(i); - - int enumeratorCount = 0; - int expectedValue = count - 1; - - foreach (var item in stack) - { - Assert.AreEqual(expectedValue, item); - enumeratorCount++; - expectedValue--; - } - - Assert.AreEqual(stack.Count, enumeratorCount); - } - } -} diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index a2ad37cf4a..adc1d6aede 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -4,6 +4,7 @@ using System.Linq; using Humanizer; using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; using osu.Game.Online.Multiplayer; using osu.Game.Tests.Visual.Multiplayer; @@ -34,13 +35,39 @@ namespace osu.Game.Tests.NonVisual.Multiplayer changeState(6, MultiplayerUserState.WaitingForLoad); checkPlayingUserCount(6); - AddStep("another user left", () => Client.RemoveUser(Client.Room?.Users.Last().User)); + AddStep("another user left", () => Client.RemoveUser((Client.Room?.Users.Last().User).AsNonNull())); checkPlayingUserCount(5); AddStep("leave room", () => Client.LeaveRoom()); checkPlayingUserCount(0); } + [Test] + public void TestPlayingUsersUpdatedOnJoin() + { + AddStep("leave room", () => Client.LeaveRoom()); + AddUntilStep("wait for room part", () => Client.Room == null); + + AddStep("create room initially in gameplay", () => + { + Room.RoomID.Value = null; + Client.RoomSetupAction = room => + { + room.State = MultiplayerRoomState.Playing; + room.Users.Add(new MultiplayerRoomUser(PLAYER_1_ID) + { + User = new User { Id = PLAYER_1_ID }, + State = MultiplayerUserState.Playing + }); + }; + + RoomManager.CreateRoom(Room); + }); + + AddUntilStep("wait for room join", () => Client.Room != null); + checkPlayingUserCount(1); + } + private void checkPlayingUserCount(int expectedCount) => AddAssert($"{"user".ToQuantity(expectedCount)} playing", () => Client.CurrentMatchPlayingUserIds.Count == expectedCount); diff --git a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs new file mode 100644 index 0000000000..93cd9403ce --- /dev/null +++ b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs @@ -0,0 +1,143 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Game.Rulesets.Difficulty.Utils; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class ReverseQueueTest + { + private ReverseQueue queue; + + [SetUp] + public void Setup() + { + queue = new ReverseQueue(4); + } + + [Test] + public void TestEmptyQueue() + { + Assert.AreEqual(0, queue.Count); + + Assert.Throws(() => + { + char unused = queue[0]; + }); + + int count = 0; + foreach (var unused in queue) + count++; + + Assert.AreEqual(0, count); + } + + [Test] + public void TestEnqueue() + { + // Assert correct values and reverse index after enqueueing + queue.Enqueue('a'); + queue.Enqueue('b'); + queue.Enqueue('c'); + + Assert.AreEqual('c', queue[0]); + Assert.AreEqual('b', queue[1]); + Assert.AreEqual('a', queue[2]); + + // Assert correct values and reverse index after enqueueing beyond initial capacity of 4 + queue.Enqueue('d'); + queue.Enqueue('e'); + queue.Enqueue('f'); + + Assert.AreEqual('f', queue[0]); + Assert.AreEqual('e', queue[1]); + Assert.AreEqual('d', queue[2]); + Assert.AreEqual('c', queue[3]); + Assert.AreEqual('b', queue[4]); + Assert.AreEqual('a', queue[5]); + } + + [Test] + public void TestDequeue() + { + queue.Enqueue('a'); + queue.Enqueue('b'); + queue.Enqueue('c'); + queue.Enqueue('d'); + queue.Enqueue('e'); + queue.Enqueue('f'); + + // Assert correct item return and no longer in queue after dequeueing + Assert.AreEqual('a', queue[5]); + var dequeuedItem = queue.Dequeue(); + + Assert.AreEqual('a', dequeuedItem); + Assert.AreEqual(5, queue.Count); + Assert.AreEqual('f', queue[0]); + Assert.AreEqual('b', queue[4]); + Assert.Throws(() => + { + char unused = queue[5]; + }); + + // Assert correct state after enough enqueues and dequeues to wrap around array (queue.start = 0 again) + queue.Enqueue('g'); + queue.Enqueue('h'); + queue.Enqueue('i'); + queue.Dequeue(); + queue.Dequeue(); + queue.Dequeue(); + queue.Dequeue(); + queue.Dequeue(); + queue.Dequeue(); + queue.Dequeue(); + + Assert.AreEqual(1, queue.Count); + Assert.AreEqual('i', queue[0]); + } + + [Test] + public void TestClear() + { + queue.Enqueue('a'); + queue.Enqueue('b'); + queue.Enqueue('c'); + queue.Enqueue('d'); + queue.Enqueue('e'); + queue.Enqueue('f'); + + // Assert queue is empty after clearing + queue.Clear(); + + Assert.AreEqual(0, queue.Count); + Assert.Throws(() => + { + char unused = queue[0]; + }); + } + + [Test] + public void TestEnumerator() + { + queue.Enqueue('a'); + queue.Enqueue('b'); + queue.Enqueue('c'); + queue.Enqueue('d'); + queue.Enqueue('e'); + queue.Enqueue('f'); + + char[] expectedValues = { 'f', 'e', 'd', 'c', 'b', 'a' }; + int expectedValueIndex = 0; + + // Assert items are enumerated in correct order + foreach (var item in queue) + { + Assert.AreEqual(expectedValues[expectedValueIndex], item); + expectedValueIndex++; + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/SessionStaticsTest.cs b/osu.Game.Tests/NonVisual/SessionStaticsTest.cs new file mode 100644 index 0000000000..d5fd803986 --- /dev/null +++ b/osu.Game.Tests/NonVisual/SessionStaticsTest.cs @@ -0,0 +1,47 @@ +// 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.Game.Configuration; +using osu.Game.Input; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class SessionStaticsTest + { + private SessionStatics sessionStatics; + private IdleTracker sessionIdleTracker; + + [SetUp] + public void SetUp() + { + sessionStatics = new SessionStatics(); + sessionIdleTracker = new GameIdleTracker(1000); + + sessionStatics.SetValue(Static.LoginOverlayDisplayed, true); + sessionStatics.SetValue(Static.MutedAudioNotificationShownOnce, true); + sessionStatics.SetValue(Static.LowBatteryNotificationShownOnce, true); + sessionStatics.SetValue(Static.LastHoverSoundPlaybackTime, (double?)1d); + + sessionIdleTracker.IsIdle.BindValueChanged(e => + { + if (e.NewValue) + sessionStatics.ResetValues(); + }); + } + + [Test] + [Timeout(2000)] + public void TestSessionStaticsReset() + { + sessionIdleTracker.IsIdle.BindValueChanged(e => + { + Assert.IsTrue(sessionStatics.GetBindable(Static.LoginOverlayDisplayed).IsDefault); + Assert.IsTrue(sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).IsDefault); + Assert.IsTrue(sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).IsDefault); + Assert.IsTrue(sessionStatics.GetBindable(Static.LastHoverSoundPlaybackTime).IsDefault); + }); + } + } +} diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs index da004b9088..b08a228de3 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.NonVisual.Skinning } public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotSupportedException(); - public Sample GetSample(ISampleInfo sampleInfo) => throw new NotSupportedException(); + public ISample GetSample(ISampleInfo sampleInfo) => throw new NotSupportedException(); public IBindable GetConfig(TLookup lookup) => throw new NotSupportedException(); } diff --git a/osu.Game.Tests/NonVisual/StreamingFramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/StreamingFramedReplayInputHandlerTest.cs deleted file mode 100644 index 21ec29b10b..0000000000 --- a/osu.Game.Tests/NonVisual/StreamingFramedReplayInputHandlerTest.cs +++ /dev/null @@ -1,296 +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 NUnit.Framework; -using osu.Game.Replays; -using osu.Game.Rulesets.Replays; - -namespace osu.Game.Tests.NonVisual -{ - [TestFixture] - public class StreamingFramedReplayInputHandlerTest - { - private Replay replay; - private TestInputHandler handler; - - [SetUp] - public void SetUp() - { - handler = new TestInputHandler(replay = new Replay - { - HasReceivedAllFrames = false, - Frames = new List - { - new TestReplayFrame(0), - new TestReplayFrame(1000), - new TestReplayFrame(2000), - new TestReplayFrame(3000, true), - new TestReplayFrame(4000, true), - new TestReplayFrame(5000, true), - new TestReplayFrame(7000, true), - new TestReplayFrame(8000), - } - }); - } - - [Test] - public void TestNormalPlayback() - { - Assert.IsNull(handler.CurrentFrame); - - confirmCurrentFrame(null); - confirmNextFrame(0); - - setTime(0, 0); - confirmCurrentFrame(0); - confirmNextFrame(1); - - // if we hit the first frame perfectly, time should progress to it. - setTime(1000, 1000); - confirmCurrentFrame(1); - confirmNextFrame(2); - - // in between non-important frames should progress based on input. - setTime(1200, 1200); - confirmCurrentFrame(1); - - setTime(1400, 1400); - confirmCurrentFrame(1); - - // progressing beyond the next frame should force time to that frame once. - setTime(2200, 2000); - confirmCurrentFrame(2); - - // second attempt should progress to input time - setTime(2200, 2200); - confirmCurrentFrame(2); - - // entering important section - setTime(3000, 3000); - confirmCurrentFrame(3); - - // cannot progress within - setTime(3500, null); - confirmCurrentFrame(3); - - setTime(4000, 4000); - confirmCurrentFrame(4); - - // still cannot progress - setTime(4500, null); - confirmCurrentFrame(4); - - setTime(5200, 5000); - confirmCurrentFrame(5); - - // important section AllowedImportantTimeSpan allowance - setTime(5200, 5200); - confirmCurrentFrame(5); - - setTime(7200, 7000); - confirmCurrentFrame(6); - - setTime(7200, null); - confirmCurrentFrame(6); - - // exited important section - setTime(8200, 8000); - confirmCurrentFrame(7); - confirmNextFrame(null); - - setTime(8200, null); - confirmCurrentFrame(7); - confirmNextFrame(null); - - setTime(8400, null); - confirmCurrentFrame(7); - confirmNextFrame(null); - } - - [Test] - public void TestIntroTime() - { - setTime(-1000, -1000); - confirmCurrentFrame(null); - confirmNextFrame(0); - - setTime(-500, -500); - confirmCurrentFrame(null); - confirmNextFrame(0); - - setTime(0, 0); - confirmCurrentFrame(0); - confirmNextFrame(1); - } - - [Test] - public void TestBasicRewind() - { - setTime(2800, 0); - setTime(2800, 1000); - setTime(2800, 2000); - setTime(2800, 2800); - confirmCurrentFrame(2); - confirmNextFrame(3); - - // pivot without crossing a frame boundary - setTime(2700, 2700); - confirmCurrentFrame(2); - confirmNextFrame(1); - - // cross current frame boundary; should not yet update frame - setTime(1980, 1980); - confirmCurrentFrame(2); - confirmNextFrame(1); - - setTime(1200, 1200); - confirmCurrentFrame(2); - confirmNextFrame(1); - - // ensure each frame plays out until start - setTime(-500, 1000); - confirmCurrentFrame(1); - confirmNextFrame(0); - - setTime(-500, 0); - confirmCurrentFrame(0); - confirmNextFrame(null); - - setTime(-500, -500); - confirmCurrentFrame(0); - confirmNextFrame(null); - } - - [Test] - public void TestRewindInsideImportantSection() - { - fastForwardToPoint(3000); - - setTime(4000, 4000); - confirmCurrentFrame(4); - confirmNextFrame(5); - - setTime(3500, null); - confirmCurrentFrame(4); - confirmNextFrame(3); - - setTime(3000, 3000); - confirmCurrentFrame(3); - confirmNextFrame(2); - - setTime(3500, null); - confirmCurrentFrame(3); - confirmNextFrame(4); - - setTime(4000, 4000); - confirmCurrentFrame(4); - confirmNextFrame(5); - - setTime(4500, null); - confirmCurrentFrame(4); - confirmNextFrame(5); - - setTime(4000, null); - confirmCurrentFrame(4); - confirmNextFrame(5); - - setTime(3500, null); - confirmCurrentFrame(4); - confirmNextFrame(3); - - setTime(3000, 3000); - confirmCurrentFrame(3); - confirmNextFrame(2); - } - - [Test] - public void TestRewindOutOfImportantSection() - { - fastForwardToPoint(3500); - - confirmCurrentFrame(3); - confirmNextFrame(4); - - setTime(3200, null); - // next frame doesn't change even though direction reversed, because of important section. - confirmCurrentFrame(3); - confirmNextFrame(4); - - setTime(3000, null); - confirmCurrentFrame(3); - confirmNextFrame(4); - - setTime(2800, 2800); - confirmCurrentFrame(3); - confirmNextFrame(2); - } - - private void fastForwardToPoint(double destination) - { - for (int i = 0; i < 1000; i++) - { - if (handler.SetFrameFromTime(destination) == null) - return; - } - - throw new TimeoutException("Seek was never fulfilled"); - } - - private void setTime(double set, double? expect) - { - Assert.AreEqual(expect, handler.SetFrameFromTime(set)); - } - - private void confirmCurrentFrame(int? frame) - { - if (frame.HasValue) - { - Assert.IsNotNull(handler.CurrentFrame); - Assert.AreEqual(replay.Frames[frame.Value].Time, handler.CurrentFrame.Time); - } - else - { - Assert.IsNull(handler.CurrentFrame); - } - } - - private void confirmNextFrame(int? frame) - { - if (frame.HasValue) - { - Assert.IsNotNull(handler.NextFrame); - Assert.AreEqual(replay.Frames[frame.Value].Time, handler.NextFrame.Time); - } - else - { - Assert.IsNull(handler.NextFrame); - } - } - - private class TestReplayFrame : ReplayFrame - { - public readonly bool IsImportant; - - public TestReplayFrame(double time, bool isImportant = false) - : base(time) - { - IsImportant = isImportant; - } - } - - private class TestInputHandler : FramedReplayInputHandler - { - public TestInputHandler(Replay replay) - : base(replay) - { - FrameAccuratePlayback = true; - } - - protected override double AllowedImportantTimeSpan => 1000; - - protected override bool IsImportant(TestReplayFrame frame) => frame.IsImportant; - } - } -} diff --git a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index ab24a72a12..ad2007f202 100644 --- a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -11,7 +11,10 @@ using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; +using osu.Game.Scoring; namespace osu.Game.Tests.Online { @@ -25,7 +28,7 @@ namespace osu.Game.Tests.Online var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); - Assert.That(deserialized.Acronym, Is.EqualTo(apiMod.Acronym)); + Assert.That(deserialized?.Acronym, Is.EqualTo(apiMod.Acronym)); } [Test] @@ -35,7 +38,7 @@ namespace osu.Game.Tests.Online var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); - Assert.That(deserialized.Settings, Contains.Key("test_setting").With.ContainValue(2.0)); + Assert.That(deserialized?.Settings, Contains.Key("test_setting").With.ContainValue(2.0)); } [Test] @@ -44,9 +47,9 @@ namespace osu.Game.Tests.Online var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } }); var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); - var converted = (TestMod)deserialized.ToMod(new TestRuleset()); + var converted = (TestMod)deserialized?.ToMod(new TestRuleset()); - Assert.That(converted.TestSetting.Value, Is.EqualTo(2)); + Assert.That(converted?.TestSetting.Value, Is.EqualTo(2)); } [Test] @@ -61,11 +64,11 @@ namespace osu.Game.Tests.Online }); var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); - var converted = (TestModTimeRamp)deserialised.ToMod(new TestRuleset()); + var converted = (TestModTimeRamp)deserialised?.ToMod(new TestRuleset()); - Assert.That(converted.AdjustPitch.Value, Is.EqualTo(false)); - Assert.That(converted.InitialRate.Value, Is.EqualTo(1.25)); - Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25)); + Assert.That(converted?.AdjustPitch.Value, Is.EqualTo(false)); + Assert.That(converted?.InitialRate.Value, Is.EqualTo(1.25)); + Assert.That(converted?.FinalRate.Value, Is.EqualTo(0.25)); } [Test] @@ -78,10 +81,40 @@ namespace osu.Game.Tests.Online }); var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); - var converted = (TestModDifficultyAdjust)deserialised.ToMod(new TestRuleset()); + var converted = (TestModDifficultyAdjust)deserialised?.ToMod(new TestRuleset()); - Assert.That(converted.ExtendedLimits.Value, Is.True); - Assert.That(converted.OverallDifficulty.Value, Is.EqualTo(11)); + Assert.That(converted?.ExtendedLimits.Value, Is.True); + Assert.That(converted?.OverallDifficulty.Value, Is.EqualTo(11)); + } + + [Test] + public void TestDeserialiseScoreInfoWithEmptyMods() + { + var score = new ScoreInfo { Ruleset = new OsuRuleset().RulesetInfo }; + + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); + + if (deserialised != null) + deserialised.Ruleset = new OsuRuleset().RulesetInfo; + + Assert.That(deserialised?.Mods.Length, Is.Zero); + } + + [Test] + public void TestDeserialiseScoreInfoWithCustomModSetting() + { + var score = new ScoreInfo + { + Ruleset = new OsuRuleset().RulesetInfo, + Mods = new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 2 } } } + }; + + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); + + if (deserialised != null) + deserialised.Ruleset = new OsuRuleset().RulesetInfo; + + Assert.That(((OsuModDoubleTime)deserialised?.Mods[0])?.SpeedChange.Value, Is.EqualTo(2)); } private class TestRuleset : Ruleset @@ -107,6 +140,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TM"; + public override string Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Test")] @@ -123,6 +157,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TMTR"; + public override string Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Initial rate", "The starting speed of the track")] diff --git a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs index 74db477cfc..0462e9feb5 100644 --- a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs @@ -100,6 +100,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TM"; + public override string Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Test")] @@ -116,6 +117,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TMTR"; + public override string Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Initial rate", "The starting speed of the track")] @@ -150,6 +152,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TM"; + public override string Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Test")] diff --git a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs index 42948c3731..aa29d76843 100644 --- a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs +++ b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs @@ -23,8 +23,10 @@ namespace osu.Game.Tests.Online { case CommentVoteRequest cRequest: cRequest.TriggerSuccess(new CommentBundle()); - break; + return true; } + + return false; }); CommentVoteRequest request = null; @@ -108,8 +110,10 @@ namespace osu.Game.Tests.Online { case LeaveChannelRequest cRequest: cRequest.TriggerSuccess(); - break; + return true; } + + return false; }); } } diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 3ffb512b7f..42848ffc0c 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -12,6 +12,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -38,13 +39,13 @@ namespace osu.Game.Tests.Online private BeatmapSetInfo testBeatmapSet; private readonly Bindable selectedItem = new Bindable(); - private OnlinePlayBeatmapAvailablilityTracker availablilityTracker; + private OnlinePlayBeatmapAvailabilityTracker availabilityTracker; [BackgroundDependencyLoader] private void load(AudioManager audio, GameHost host) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.CacheAs(beatmaps = new TestBeatmapManager(LocalStorage, ContextFactory, rulesets, API, audio, host, Beatmap.Default)); + Dependencies.CacheAs(beatmaps = new TestBeatmapManager(LocalStorage, ContextFactory, rulesets, API, audio, Resources, host, Beatmap.Default)); } [SetUp] @@ -52,7 +53,7 @@ namespace osu.Game.Tests.Online { beatmaps.AllowImport = new TaskCompletionSource(); - testBeatmapFile = TestResources.GetTestBeatmapForImport(); + testBeatmapFile = TestResources.GetQuickTestBeatmapForImport(); testBeatmapInfo = getTestBeatmapInfo(testBeatmapFile); testBeatmapSet = testBeatmapInfo.BeatmapSet; @@ -67,7 +68,7 @@ namespace osu.Game.Tests.Online Ruleset = { Value = testBeatmapInfo.Ruleset }, }; - Child = availablilityTracker = new OnlinePlayBeatmapAvailablilityTracker + Child = availabilityTracker = new OnlinePlayBeatmapAvailabilityTracker { SelectedItem = { BindTarget = selectedItem, } }; @@ -118,7 +119,7 @@ namespace osu.Game.Tests.Online }); addAvailabilityCheckStep("state still not downloaded", BeatmapAvailability.NotDownloaded); - AddStep("recreate tracker", () => Child = availablilityTracker = new OnlinePlayBeatmapAvailablilityTracker + AddStep("recreate tracker", () => Child = availabilityTracker = new OnlinePlayBeatmapAvailabilityTracker { SelectedItem = { BindTarget = selectedItem } }); @@ -127,7 +128,7 @@ namespace osu.Game.Tests.Online private void addAvailabilityCheckStep(string description, Func expected) { - AddAssert(description, () => availablilityTracker.Availability.Value.Equals(expected.Invoke())); + AddAssert(description, () => availabilityTracker.Availability.Value.Equals(expected.Invoke())); } private static BeatmapInfo getTestBeatmapInfo(string archiveFile) @@ -160,8 +161,8 @@ namespace osu.Game.Tests.Online protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) => new TestDownloadRequest(set); - public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false) - : base(storage, contextFactory, rulesets, api, audioManager, host, defaultBeatmap, performOnlineLookups) + public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false) + : base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap, performOnlineLookups) { } diff --git a/osu.Game.Tests/OnlinePlay/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/OnlinePlay/StatefulMultiplayerClientTest.cs new file mode 100644 index 0000000000..82ce588c6f --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/StatefulMultiplayerClientTest.cs @@ -0,0 +1,35 @@ +// 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.Testing; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Users; + +namespace osu.Game.Tests.OnlinePlay +{ + [HeadlessTest] + public class StatefulMultiplayerClientTest : MultiplayerTestScene + { + [Test] + public void TestUserAddedOnJoin() + { + var user = new User { Id = 33 }; + + AddRepeatStep("add user multiple times", () => Client.AddUser(user), 3); + AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + } + + [Test] + public void TestUserRemovedOnLeave() + { + var user = new User { Id = 44 }; + + AddStep("add user", () => Client.AddUser(user)); + AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + + AddRepeatStep("remove user multiple times", () => Client.RemoveUser(user), 3); + AddAssert("room has 1 user", () => Client.Room?.Users.Count == 1); + } + } +} diff --git a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs new file mode 100644 index 0000000000..d4e591cf09 --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs @@ -0,0 +1,223 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.OnlinePlay +{ + [HeadlessTest] + public class TestSceneCatchUpSyncManager : OsuTestScene + { + private TestManualClock master; + private CatchUpSyncManager syncManager; + + private TestSpectatorPlayerClock player1; + private TestSpectatorPlayerClock player2; + + [SetUp] + public void Setup() + { + syncManager = new CatchUpSyncManager(master = new TestManualClock()); + syncManager.AddPlayerClock(player1 = new TestSpectatorPlayerClock(1)); + syncManager.AddPlayerClock(player2 = new TestSpectatorPlayerClock(2)); + + Schedule(() => Child = syncManager); + } + + [Test] + public void TestMasterClockStartsWhenAllPlayerClocksHaveFrames() + { + setWaiting(() => player1, false); + assertMasterState(false); + assertPlayerClockState(() => player1, false); + assertPlayerClockState(() => player2, false); + + setWaiting(() => player2, false); + assertMasterState(true); + assertPlayerClockState(() => player1, true); + assertPlayerClockState(() => player2, true); + } + + [Test] + public void TestMasterClockDoesNotStartWhenNoneReadyForMaximumDelayTime() + { + AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + assertMasterState(false); + } + + [Test] + public void TestMasterClockStartsWhenAnyReadyForMaximumDelayTime() + { + setWaiting(() => player1, false); + AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + assertMasterState(true); + } + + [Test] + public void TestPlayerClockDoesNotCatchUpWhenSlightlyOutOfSync() + { + setAllWaiting(false); + + setMasterTime(CatchUpSyncManager.SYNC_TARGET + 1); + assertCatchingUp(() => player1, false); + } + + [Test] + public void TestPlayerClockStartsCatchingUpWhenTooFarBehind() + { + setAllWaiting(false); + + setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1); + assertCatchingUp(() => player1, true); + assertCatchingUp(() => player2, true); + } + + [Test] + public void TestPlayerClockKeepsCatchingUpWhenSlightlyOutOfSync() + { + setAllWaiting(false); + + setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1); + setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET + 1); + assertCatchingUp(() => player1, true); + } + + [Test] + public void TestPlayerClockStopsCatchingUpWhenInSync() + { + setAllWaiting(false); + + setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 2); + setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET); + assertCatchingUp(() => player1, false); + assertCatchingUp(() => player2, true); + } + + [Test] + public void TestPlayerClockDoesNotStopWhenSlightlyAhead() + { + setAllWaiting(false); + + setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET); + assertCatchingUp(() => player1, false); + assertPlayerClockState(() => player1, true); + } + + [Test] + public void TestPlayerClockStopsWhenTooFarAheadAndStartsWhenBackInSync() + { + setAllWaiting(false); + + setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET - 1); + + // This is a silent catchup, where IsCatchingUp = false but IsRunning = false also. + assertCatchingUp(() => player1, false); + assertPlayerClockState(() => player1, false); + + setMasterTime(1); + assertCatchingUp(() => player1, false); + assertPlayerClockState(() => player1, true); + } + + [Test] + public void TestInSyncPlayerClockDoesNotStartIfWaitingOnFrames() + { + setAllWaiting(false); + + assertPlayerClockState(() => player1, true); + setWaiting(() => player1, true); + assertPlayerClockState(() => player1, false); + } + + private void setWaiting(Func playerClock, bool waiting) + => AddStep($"set player clock {playerClock().Id} waiting = {waiting}", () => playerClock().WaitingOnFrames.Value = waiting); + + private void setAllWaiting(bool waiting) => AddStep($"set all player clocks waiting = {waiting}", () => + { + player1.WaitingOnFrames.Value = waiting; + player2.WaitingOnFrames.Value = waiting; + }); + + private void setMasterTime(double time) + => AddStep($"set master = {time}", () => master.Seek(time)); + + /// + /// clock.Time = master.Time - offsetFromMaster + /// + private void setPlayerClockTime(Func playerClock, double offsetFromMaster) + => AddStep($"set player clock {playerClock().Id} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster)); + + private void assertMasterState(bool running) + => AddAssert($"master clock {(running ? "is" : "is not")} running", () => master.IsRunning == running); + + private void assertCatchingUp(Func playerClock, bool catchingUp) => + AddAssert($"player clock {playerClock().Id} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp); + + private void assertPlayerClockState(Func playerClock, bool running) + => AddAssert($"player clock {playerClock().Id} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running); + + private class TestSpectatorPlayerClock : TestManualClock, ISpectatorPlayerClock + { + public Bindable WaitingOnFrames { get; } = new Bindable(true); + + public bool IsCatchingUp { get; set; } + + public IFrameBasedClock Source + { + set => throw new NotImplementedException(); + } + + public readonly int Id; + + public TestSpectatorPlayerClock(int id) + { + Id = id; + + WaitingOnFrames.BindValueChanged(waiting => + { + if (waiting.NewValue) + Stop(); + else + Start(); + }); + } + + public void ProcessFrame() + { + } + + public double ElapsedFrameTime => 0; + + public double FramesPerSecond => 0; + + public FrameTimeInfo TimeInfo => default; + } + + private class TestManualClock : ManualClock, IAdjustableClock + { + public void Start() => IsRunning = true; + + public void Stop() => IsRunning = false; + + public bool Seek(double position) + { + CurrentTime = position; + return true; + } + + public void Reset() + { + } + + public void ResetSpeedAdjustments() + { + } + } + } +} diff --git a/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus_virtual_quick.osz b/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus_virtual_quick.osz new file mode 100644 index 0000000000..e9f5fb0328 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus_virtual_quick.osz differ diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index e882229570..cef0532f9d 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -15,6 +15,28 @@ namespace osu.Game.Tests.Resources public static Stream GetTestBeatmapStream(bool virtualTrack = false) => OpenResource($"Archives/241526 Soleily - Renatus{(virtualTrack ? "_virtual" : "")}.osz"); + /// + /// Retrieve a path to a copy of a shortened (~10 second) beatmap archive with a virtual track. + /// + /// + /// This is intended for use in tests which need to run to completion as soon as possible and don't need to test a full length beatmap. + /// A path to a copy of a beatmap archive (osz). Should be deleted after use. + public static string GetQuickTestBeatmapForImport() + { + var tempPath = Path.GetTempFileName() + ".osz"; + using (var stream = OpenResource("Archives/241526 Soleily - Renatus_virtual_quick.osz")) + using (var newFile = File.Create(tempPath)) + stream.CopyTo(newFile); + + Assert.IsTrue(File.Exists(tempPath)); + return tempPath; + } + + /// + /// Retrieve a path to a copy of a full-fledged beatmap archive. + /// + /// Whether the audio track should be virtual. + /// A path to a copy of a beatmap archive (osz). Should be deleted after use. public static string GetTestBeatmapForImport(bool virtualTrack = false) { var tempPath = Path.GetTempFileName() + ".osz"; diff --git a/osu.Game.Tests/Resources/multi-segment-slider.osu b/osu.Game.Tests/Resources/multi-segment-slider.osu index 6eabe640e4..135132e35c 100644 --- a/osu.Game.Tests/Resources/multi-segment-slider.osu +++ b/osu.Game.Tests/Resources/multi-segment-slider.osu @@ -9,3 +9,16 @@ osu file format v128 // Implicit multi-segment 32,192,3000,6,0,B|32:384|256:384|256:192|256:192|256:0|512:0|512:192,1,800 + +// Last control point duplicated +0,0,4000,2,0,B|1:1|2:2|3:3|3:3,2,200 + +// Last control point in segment duplicated +0,0,5000,2,0,B|1:1|2:2|3:3|3:3|B|4:4|5:5,2,200 + +// Implicit perfect-curve multi-segment (Should convert to bezier to match stable) +0,0,6000,2,0,P|75:145|170:75|170:75|300:145|410:20,1,475,0:0:0:0: + +// Explicit perfect-curve multi-segment (Should not convert to bezier) +0,0,7000,2,0,P|75:145|P|170:75|300:145|410:20,1,650,0:0:0:0: + diff --git a/osu.Game.Tests/Resources/skin-with-space.ini b/osu.Game.Tests/Resources/skin-with-space.ini new file mode 100644 index 0000000000..3e64257a3e --- /dev/null +++ b/osu.Game.Tests/Resources/skin-with-space.ini @@ -0,0 +1,2 @@ +[General] +Version: 2 \ No newline at end of file diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index 9f16312121..184a94912a 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Judgements; @@ -51,7 +50,7 @@ namespace osu.Game.Tests.Rulesets.Scoring }; scoreProcessor.ApplyResult(judgementResult); - Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value)); + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); } /// @@ -77,27 +76,28 @@ namespace osu.Game.Tests.Rulesets.Scoring [TestCase(ScoringMode.Standardised, HitResult.Miss, HitResult.Great, 0)] // (3 * 0) / (4 * 300) * 300_000 + (0 / 4) * 700_000 [TestCase(ScoringMode.Standardised, HitResult.Meh, HitResult.Great, 387_500)] // (3 * 50) / (4 * 300) * 300_000 + (2 / 4) * 700_000 [TestCase(ScoringMode.Standardised, HitResult.Ok, HitResult.Great, 425_000)] // (3 * 100) / (4 * 300) * 300_000 + (2 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 478_571)] // (3 * 200) / (4 * 350) * 300_000 + (2 / 4) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 492_857)] // (3 * 200) / (4 * 350) * 300_000 + (2 / 4) * 700_000 [TestCase(ScoringMode.Standardised, HitResult.Great, HitResult.Great, 575_000)] // (3 * 300) / (4 * 300) * 300_000 + (2 / 4) * 700_000 [TestCase(ScoringMode.Standardised, HitResult.Perfect, HitResult.Perfect, 575_000)] // (3 * 350) / (4 * 350) * 300_000 + (2 / 4) * 700_000 [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, HitResult.SmallTickHit, 700_000)] // (3 * 0) / (4 * 10) * 300_000 + 700_000 (max combo 0) [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, HitResult.SmallTickHit, 925_000)] // (3 * 10) / (4 * 10) * 300_000 + 700_000 (max combo 0) [TestCase(ScoringMode.Standardised, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (3 * 0) / (4 * 30) * 300_000 + (0 / 4) * 700_000 [TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 575_000)] // (3 * 30) / (4 * 30) * 300_000 + (0 / 4) * 700_000 - [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 700_030)] // 0 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points) - [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 700_150)] // 0 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points) + [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points) + [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points) [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] // (0 * 4 * 300) * (1 + 0 / 25) [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 156)] // (((3 * 50) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 312)] // (((3 * 100) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) - [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 535)] // (((3 * 200) / (4 * 350)) * 4 * 300) * (1 + 1 / 25) + [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 594)] // (((3 * 200) / (4 * 350)) * 4 * 300) * (1 + 1 / 25) [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 936)] // (((3 * 300) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 936)] // (((3 * 350) / (4 * 350)) * 4 * 300) * (1 + 1 / 25) [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] // (0 * 1 * 300) * (1 + 0 / 25) [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 225)] // (((3 * 10) / (4 * 10)) * 1 * 300) * (1 + 0 / 25) [TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (0 * 4 * 300) * (1 + 0 / 25) [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 936)] // (((3 * 50) / (4 * 50)) * 4 * 300) * (1 + 1 / 25) - [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 30)] // (0 * 1 * 300) * (1 + 0 / 25) + 3 * 10 (bonus points) - [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 150)] // (0 * 1 * 300) * (1 + 0 / 25) * 3 * 50 (bonus points) + // TODO: The following two cases don't match expectations currently (a single hit is registered in acc portion when it shouldn't be). See https://github.com/ppy/osu/issues/12604. + [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 330)] // (1 * 1 * 300) * (1 + 0 / 25) + 3 * 10 (bonus points) + [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 450)] // (1 * 1 * 300) * (1 + 0 / 25) + 3 * 50 (bonus points) public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore) { var minResult = new TestJudgement(hitResult).MinResult; @@ -118,7 +118,7 @@ namespace osu.Game.Tests.Rulesets.Scoring scoreProcessor.ApplyResult(judgementResult); } - Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5)); + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); } /// @@ -158,7 +158,7 @@ namespace osu.Game.Tests.Rulesets.Scoring }; scoreProcessor.ApplyResult(lastJudgementResult); - Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5)); + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); } [Test] @@ -169,7 +169,7 @@ namespace osu.Game.Tests.Rulesets.Scoring scoreProcessor.Mode.Value = scoringMode; scoreProcessor.ApplyBeatmap(new TestBeatmap(new RulesetInfo())); - Assert.IsTrue(Precision.AlmostEquals(0, scoreProcessor.TotalScore.Value)); + Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); } [TestCase(HitResult.IgnoreHit, HitResult.IgnoreMiss)] @@ -287,6 +287,23 @@ namespace osu.Game.Tests.Rulesets.Scoring Assert.AreEqual(expectedReturnValue, hitResult.IsScorable()); } + [TestCase(HitResult.Perfect, 1_000_000)] + [TestCase(HitResult.SmallTickHit, 1_000_000)] + [TestCase(HitResult.LargeTickHit, 1_000_000)] + [TestCase(HitResult.SmallBonus, 1_000_000 + Judgement.SMALL_BONUS_SCORE)] + [TestCase(HitResult.LargeBonus, 1_000_000 + Judgement.LARGE_BONUS_SCORE)] + public void TestGetScoreWithExternalStatistics(HitResult result, int expectedScore) + { + var statistic = new Dictionary { { result, 1 } }; + + scoreProcessor.ApplyBeatmap(new Beatmap + { + HitObjects = { new TestHitObject(result) } + }); + + Assert.That(scoreProcessor.GetImmediateScore(ScoringMode.Standardised, result.AffectsCombo() ? 1 : 0, statistic), Is.EqualTo(expectedScore).Within(0.5d)); + } + private class TestJudgement : Judgement { public override HitResult MaxResult { get; } diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs index 4aebed0d31..f421a30283 100644 --- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -118,6 +118,10 @@ namespace osu.Game.Tests.Rulesets public BindableNumber Frequency => throw new NotImplementedException(); public BindableNumber Tempo => throw new NotImplementedException(); + public void BindAdjustments(IAggregateAudioAdjustment component) => throw new NotImplementedException(); + + public void UnbindAdjustments(IAggregateAudioAdjustment component) => throw new NotImplementedException(); + public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) => throw new NotImplementedException(); public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index a5b4b04ef5..8124bd4199 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -113,6 +113,31 @@ namespace osu.Game.Tests.Skins.IO } } + [Test] + public async Task TestImportUpperCasedOskArchive() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportSkinTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var imported = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("name 1", "author 1"), "skin1.OsK")); + + Assert.That(imported.Name, Is.EqualTo("name 1")); + Assert.That(imported.Creator, Is.EqualTo("author 1")); + + var imported2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("name 1", "author 1"), "skin1.oSK")); + + Assert.That(imported2.Hash, Is.EqualTo(imported.Hash)); + } + finally + { + host.Exit(); + } + } + } + private MemoryStream createOsk(string name, string author) { var zipStream = new MemoryStream(); diff --git a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs index aedf26ee75..dcb866c99f 100644 --- a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs +++ b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs @@ -91,6 +91,15 @@ namespace osu.Game.Tests.Skins Assert.AreEqual(2.0m, decoder.Decode(stream).LegacyVersion); } + [Test] + public void TestStripWhitespace() + { + var decoder = new LegacySkinDecoder(); + using (var resStream = TestResources.OpenResource("skin-with-space.ini")) + using (var stream = new LineBufferedReader(resStream)) + Assert.AreEqual(2.0m, decoder.Decode(stream).LegacyVersion); + } + [Test] public void TestDecodeLatestVersion() { diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index 414f7d3f88..732a3f3f42 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs @@ -219,7 +219,7 @@ namespace osu.Game.Tests.Skins public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => skin.GetTexture(componentName, wrapModeS, wrapModeT); - public Sample GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); + public ISample GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => skin.GetConfig(lookup); } diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs index fba0d92d4b..dc5a4f4a3e 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Background public void SetUp() => Schedule(() => { // reset API response in statics to avoid test crosstalk. - statics.Set(Static.SeasonalBackgrounds, null); + statics.SetValue(Static.SeasonalBackgrounds, null); textureStore.PerformedLookups.Clear(); dummyAPI.SetState(APIState.Online); @@ -135,18 +135,20 @@ namespace osu.Game.Tests.Visual.Background dummyAPI.HandleRequest = request => { if (dummyAPI.State.Value != APIState.Online || !(request is GetSeasonalBackgroundsRequest backgroundsRequest)) - return; + return false; backgroundsRequest.TriggerSuccess(new APISeasonalBackgrounds { Backgrounds = seasonal_background_urls.Select(url => new APISeasonalBackground { Url = url }).ToList(), EndDate = endDate }); + + return true; }; }); private void setSeasonalBackgroundMode(SeasonalBackgroundMode mode) - => AddStep($"set seasonal mode to {mode}", () => config.Set(OsuSetting.SeasonalBackgroundMode, mode)); + => AddStep($"set seasonal mode to {mode}", () => config.SetValue(OsuSetting.SeasonalBackgroundMode, mode)); private void createLoader() => AddStep("create loader", () => diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 7ade7725d9..1670d86545 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -48,10 +48,10 @@ namespace osu.Game.Tests.Visual.Background private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(new OsuConfigManager(LocalStorage)); - manager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + manager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); Beatmap.SetDefault(); } @@ -65,6 +65,21 @@ namespace osu.Game.Tests.Visual.Background stack.Push(songSelect = new DummySongSelect()); }); + /// + /// User settings should always be ignored on song select screen. + /// + [Test] + public void TestUserSettingsIgnoredOnSongSelect() + { + setupUserSettings(); + AddUntilStep("Screen is undimmed", () => songSelect.IsBackgroundUndimmed()); + AddUntilStep("Screen using background blur", () => songSelect.IsBackgroundBlur()); + performFullSetup(); + AddStep("Exit to song select", () => player.Exit()); + AddUntilStep("Screen is undimmed", () => songSelect.IsBackgroundUndimmed()); + AddUntilStep("Screen using background blur", () => songSelect.IsBackgroundBlur()); + } + /// /// Check if properly triggers the visual settings preview when a user hovers over the visual settings panel. /// @@ -142,9 +157,9 @@ namespace osu.Game.Tests.Visual.Background { performFullSetup(); AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); - AddStep("Enable user dim", () => songSelect.DimEnabled.Value = false); + AddStep("Disable user dim", () => songSelect.IgnoreUserSettings.Value = true); AddUntilStep("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && songSelect.IsUserBlurDisabled()); - AddStep("Disable user dim", () => songSelect.DimEnabled.Value = true); + AddStep("Enable user dim", () => songSelect.IgnoreUserSettings.Value = false); AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); } @@ -161,13 +176,36 @@ namespace osu.Game.Tests.Visual.Background player.ReplacesBackground.Value = true; player.StoryboardEnabled.Value = true; }); - AddStep("Enable user dim", () => player.DimmableStoryboard.EnableUserDim.Value = true); + AddStep("Enable user dim", () => player.DimmableStoryboard.IgnoreUserSettings.Value = false); AddStep("Set dim level to 1", () => songSelect.DimLevel.Value = 1f); AddUntilStep("Storyboard is invisible", () => !player.IsStoryboardVisible); - AddStep("Disable user dim", () => player.DimmableStoryboard.EnableUserDim.Value = false); + AddStep("Disable user dim", () => player.DimmableStoryboard.IgnoreUserSettings.Value = true); AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible); } + [Test] + public void TestStoryboardIgnoreUserSettings() + { + performFullSetup(); + createFakeStoryboard(); + AddStep("Enable replacing background", () => player.ReplacesBackground.Value = true); + + AddUntilStep("Storyboard is invisible", () => !player.IsStoryboardVisible); + AddUntilStep("Background is visible", () => songSelect.IsBackgroundVisible()); + + AddStep("Ignore user settings", () => + { + player.ApplyToBackground(b => b.IgnoreUserSettings.Value = true); + player.DimmableStoryboard.IgnoreUserSettings.Value = true; + }); + AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible); + AddUntilStep("Background is invisible", () => songSelect.IsBackgroundInvisible()); + + AddStep("Disable background replacement", () => player.ReplacesBackground.Value = false); + AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible); + AddUntilStep("Background is visible", () => songSelect.IsBackgroundVisible()); + } + /// /// Check if the visual settings container retains dim and blur when pausing /// @@ -204,17 +242,6 @@ namespace osu.Game.Tests.Visual.Background songSelect.IsBackgroundUndimmed() && songSelect.IsBackgroundCurrent() && songSelect.CheckBackgroundBlur(results.ExpectedBackgroundBlur)); } - /// - /// Check if background gets undimmed and unblurred when leaving for - /// - [Test] - public void TestTransitionOut() - { - performFullSetup(); - AddStep("Exit to song select", () => player.Exit()); - AddUntilStep("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && songSelect.IsBlurCorrect()); - } - /// /// Check if hovering on the visual settings dialogue after resuming from player still previews the background dim. /// @@ -281,11 +308,11 @@ namespace osu.Game.Tests.Visual.Background protected override BackgroundScreen CreateBackground() { background = new FadeAccessibleBackground(Beatmap.Value); - DimEnabled.BindTo(background.EnableUserDim); + IgnoreUserSettings.BindTo(background.IgnoreUserSettings); return background; } - public readonly Bindable DimEnabled = new Bindable(); + public readonly Bindable IgnoreUserSettings = new Bindable(); public readonly Bindable DimLevel = new BindableDouble(); public readonly Bindable BlurLevel = new BindableDouble(); @@ -310,7 +337,7 @@ namespace osu.Game.Tests.Visual.Background public bool IsBackgroundVisible() => background.CurrentAlpha == 1; - public bool IsBlurCorrect() => background.CurrentBlur == new Vector2(BACKGROUND_BLUR); + public bool IsBackgroundBlur() => background.CurrentBlur == new Vector2(BACKGROUND_BLUR); public bool CheckBackgroundBlur(Vector2 expected) => background.CurrentBlur == expected; diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index fef1605f0c..28218ea220 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -36,15 +36,15 @@ namespace osu.Game.Tests.Visual.Collections private void load(GameHost host) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, Resources, host, Beatmap.Default)); - beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); base.Content.AddRange(new Drawable[] { manager = new CollectionManager(LocalStorage), Content, - dialogOverlay = new DialogOverlay() + dialogOverlay = new DialogOverlay(), }); Dependencies.Cache(manager); @@ -134,6 +134,27 @@ namespace osu.Game.Tests.Visual.Collections assertCollectionName(0, "2"); } + [Test] + public void TestCollectionNameCollisions() + { + AddStep("add dropdown", () => + { + Add(new CollectionFilterDropdown + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + Width = 0.4f, + } + ); + }); + AddStep("add two collections with same name", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "1" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, + })); + } + [Test] public void TestRemoveCollectionViaButton() { diff --git a/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs b/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs index 4d64c7d35d..86a9d555a3 100644 --- a/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs +++ b/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs @@ -81,6 +81,13 @@ namespace osu.Game.Tests.Visual.Components [Test] public void TestMovement() { + checkIdleStatus(1, false); + checkIdleStatus(2, false); + checkIdleStatus(3, false); + checkIdleStatus(4, false); + + waitForAllIdle(); + AddStep("move to top right", () => InputManager.MoveMouseTo(box2)); checkIdleStatus(1, true); @@ -102,6 +109,8 @@ namespace osu.Game.Tests.Visual.Components [Test] public void TestTimings() { + waitForAllIdle(); + AddStep("move to centre", () => InputManager.MoveMouseTo(Content)); checkIdleStatus(1, false); @@ -140,7 +149,7 @@ namespace osu.Game.Tests.Visual.Components private void waitForAllIdle() { - AddUntilStep("Wait for all idle", () => box1.IsIdle && box2.IsIdle && box3.IsIdle && box4.IsIdle); + AddUntilStep("wait for all idle", () => box1.IsIdle && box2.IsIdle && box3.IsIdle && box4.IsIdle); } private class IdleTrackingBox : CompositeDrawable @@ -149,7 +158,7 @@ namespace osu.Game.Tests.Visual.Components public bool IsIdle => idleTracker.IsIdle.Value; - public IdleTrackingBox(double timeToIdle) + public IdleTrackingBox(int timeToIdle) { Box box; @@ -158,7 +167,7 @@ namespace osu.Game.Tests.Visual.Components InternalChildren = new Drawable[] { - idleTracker = new IdleTracker(timeToIdle), + idleTracker = new GameIdleTracker(timeToIdle), box = new Box { Colour = Color4.White, diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs new file mode 100644 index 0000000000..976bf93c15 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneBlueprintSelection.cs @@ -0,0 +1,70 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneBlueprintSelection : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private EditorBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + [Test] + public void TestSelectedObjectHasPriorityWhenOverlapping() + { + var firstSlider = new Slider + { + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2()), + new PathControlPoint(new Vector2(150, -50)), + new PathControlPoint(new Vector2(300, 0)) + }), + Position = new Vector2(0, 100) + }; + var secondSlider = new Slider + { + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2()), + new PathControlPoint(new Vector2(-50, 50)), + new PathControlPoint(new Vector2(-100, 100)) + }), + Position = new Vector2(200, 0) + }; + + AddStep("add overlapping sliders", () => + { + EditorBeatmap.Add(firstSlider); + EditorBeatmap.Add(secondSlider); + }); + AddStep("select first slider", () => EditorBeatmap.SelectedHitObjects.Add(firstSlider)); + + AddStep("move mouse to common point", () => + { + var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; + InputManager.MoveMouseTo(pos); + }); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + + AddAssert("selection is unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == firstSlider); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs index cf5f1b8818..d5cfeb1878 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs @@ -1,45 +1,50 @@ // 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.Testing; +using osu.Framework.Threading; using osu.Game.Screens.Edit.Compose.Components; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public class TestSceneComposeSelectBox : OsuTestScene + public class TestSceneComposeSelectBox : OsuManualInputManagerTestScene { private Container selectionArea; + private SelectionBox selectionBox; - public TestSceneComposeSelectBox() + [SetUp] + public void SetUp() => Schedule(() => { - SelectionBox selectionBox = null; - - AddStep("create box", () => - Child = selectionArea = new Container + Child = selectionArea = new Container + { + Size = new Vector2(400), + Position = -new Vector2(150), + Anchor = Anchor.Centre, + Children = new Drawable[] { - Size = new Vector2(400), - Position = -new Vector2(150), - Anchor = Anchor.Centre, - Children = new Drawable[] + selectionBox = new SelectionBox { - selectionBox = new SelectionBox - { - CanRotate = true, - CanScaleX = true, - CanScaleY = true, + RelativeSizeAxes = Axes.Both, - OnRotation = handleRotation, - OnScale = handleScale - } + CanRotate = true, + CanScaleX = true, + CanScaleY = true, + + OnRotation = handleRotation, + OnScale = handleScale } - }); + } + }; - AddToggleStep("toggle rotation", state => selectionBox.CanRotate = state); - AddToggleStep("toggle x", state => selectionBox.CanScaleX = state); - AddToggleStep("toggle y", state => selectionBox.CanScaleY = state); - } + InputManager.MoveMouseTo(selectionBox); + InputManager.ReleaseButton(MouseButton.Left); + }); private bool handleScale(Vector2 amount, Anchor reference) { @@ -68,5 +73,115 @@ namespace osu.Game.Tests.Visual.Editing selectionArea.Rotation += angle; return true; } + + [Test] + public void TestRotationHandleShownOnHover() + { + SelectionBoxRotationHandle rotationHandle = null; + + AddStep("retrieve rotation handle", () => rotationHandle = this.ChildrenOfType().First()); + + AddAssert("handle hidden", () => rotationHandle.Alpha == 0); + AddStep("hover over handle", () => InputManager.MoveMouseTo(rotationHandle)); + AddUntilStep("rotation handle shown", () => rotationHandle.Alpha == 1); + + AddStep("move mouse away", () => InputManager.MoveMouseTo(selectionBox)); + AddUntilStep("handle hidden", () => rotationHandle.Alpha == 0); + } + + [Test] + public void TestRotationHandleShownOnHoveringClosestScaleHandler() + { + SelectionBoxRotationHandle rotationHandle = null; + + AddStep("retrieve rotation handle", () => rotationHandle = this.ChildrenOfType().First()); + + AddAssert("rotation handle hidden", () => rotationHandle.Alpha == 0); + AddStep("hover over closest scale handle", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single(s => s.Anchor == rotationHandle.Anchor)); + }); + AddUntilStep("rotation handle shown", () => rotationHandle.Alpha == 1); + + AddStep("move mouse away", () => InputManager.MoveMouseTo(selectionBox)); + AddUntilStep("handle hidden", () => rotationHandle.Alpha == 0); + } + + [Test] + public void TestHoverRotationHandleFromScaleHandle() + { + SelectionBoxRotationHandle rotationHandle = null; + + AddStep("retrieve rotation handle", () => rotationHandle = this.ChildrenOfType().First()); + + AddAssert("rotation handle hidden", () => rotationHandle.Alpha == 0); + AddStep("hover over closest scale handle", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single(s => s.Anchor == rotationHandle.Anchor)); + }); + AddUntilStep("rotation handle shown", () => rotationHandle.Alpha == 1); + AddAssert("rotation handle not hovered", () => !rotationHandle.IsHovered); + + AddStep("hover over rotation handle", () => InputManager.MoveMouseTo(rotationHandle)); + AddAssert("rotation handle still shown", () => rotationHandle.Alpha == 1); + AddAssert("rotation handle hovered", () => rotationHandle.IsHovered); + + AddStep("move mouse away", () => InputManager.MoveMouseTo(selectionBox)); + AddUntilStep("handle hidden", () => rotationHandle.Alpha == 0); + } + + [Test] + public void TestHoldingScaleHandleHidesCorrespondingRotationHandle() + { + SelectionBoxRotationHandle rotationHandle = null; + + AddStep("retrieve rotation handle", () => rotationHandle = this.ChildrenOfType().First()); + + AddAssert("rotation handle hidden", () => rotationHandle.Alpha == 0); + AddStep("hover over closest scale handle", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single(s => s.Anchor == rotationHandle.Anchor)); + }); + AddUntilStep("rotation handle shown", () => rotationHandle.Alpha == 1); + AddStep("hold scale handle", () => InputManager.PressButton(MouseButton.Left)); + AddUntilStep("rotation handle hidden", () => rotationHandle.Alpha == 0); + + int i; + ScheduledDelegate mouseMove = null; + + AddStep("start dragging", () => + { + i = 0; + + mouseMove = Scheduler.AddDelayed(() => + { + InputManager.MoveMouseTo(selectionBox.ScreenSpaceDrawQuad.TopLeft + Vector2.One * (5 * ++i)); + }, 100, true); + }); + AddAssert("rotation handle still hidden", () => rotationHandle.Alpha == 0); + + AddStep("end dragging", () => mouseMove.Cancel()); + AddAssert("rotation handle still hidden", () => rotationHandle.Alpha == 0); + AddStep("unhold left", () => InputManager.ReleaseButton(MouseButton.Left)); + AddUntilStep("rotation handle shown", () => rotationHandle.Alpha == 1); + AddStep("move mouse away", () => InputManager.MoveMouseTo(selectionBox, new Vector2(20))); + AddUntilStep("rotation handle hidden", () => rotationHandle.Alpha == 0); + } + + /// + /// Tests that hovering over two handles instantaneously from one to another does not crash or cause issues to the visibility state. + /// + [Test] + public void TestHoverOverTwoHandlesInstantaneously() + { + AddStep("hover over top-left scale handle", () => + InputManager.MoveMouseTo(this.ChildrenOfType().Single(s => s.Anchor == Anchor.TopLeft))); + AddStep("hover over top-right scale handle", () => + InputManager.MoveMouseTo(this.ChildrenOfType().Single(s => s.Anchor == Anchor.TopRight))); + AddUntilStep("top-left rotation handle hidden", () => + this.ChildrenOfType().Single(r => r.Anchor == Anchor.TopLeft).Alpha == 0); + AddUntilStep("top-right rotation handle shown", () => + this.ChildrenOfType().Single(r => r.Anchor == Anchor.TopRight).Alpha == 1); + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index 7584c74c71..07162c3cd1 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -24,13 +24,13 @@ namespace osu.Game.Tests.Visual.Editing protected override bool EditorComponentsReady => Editor.ChildrenOfType().SingleOrDefault()?.IsLoaded == true; + protected override bool IsolateSavingFromDatabase => false; + [Resolved] private BeatmapManager beatmapManager { get; set; } public override void SetUpSteps() { - AddStep("set dummy", () => Beatmap.Value = new DummyWorkingBeatmap(Audio, null)); - base.SetUpSteps(); // if we save a beatmap with a hash collision, things fall over. @@ -38,6 +38,12 @@ namespace osu.Game.Tests.Visual.Editing AddStep("make new beatmap unique", () => EditorBeatmap.Metadata.Title = Guid.NewGuid().ToString()); } + protected override void LoadEditor() + { + Beatmap.Value = new DummyWorkingBeatmap(Audio, null); + base.LoadEditor(); + } + [Test] public void TestCreateNewBeatmap() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs index 29046c82a6..3aff74a0a8 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -3,12 +3,16 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Screens.Edit.Compose.Components.Timeline; using osu.Game.Tests.Beatmaps; using osuTK; @@ -110,8 +114,9 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("duration matches", () => ((Spinner)EditorBeatmap.HitObjects.Single()).Duration == 5000); } - [Test] - public void TestCopyPaste() + [TestCase(false)] + [TestCase(true)] + public void TestCopyPaste(bool deselectAfterCopy) { var addedObject = new HitCircle { StartTime = 1000 }; @@ -123,11 +128,22 @@ namespace osu.Game.Tests.Visual.Editing AddStep("move forward in time", () => EditorClock.Seek(2000)); + if (deselectAfterCopy) + { + AddStep("deselect", () => EditorBeatmap.SelectedHitObjects.Clear()); + + AddUntilStep("timeline selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0); + AddUntilStep("composer selection box is not visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha == 0); + } + AddStep("paste hitobject", () => Editor.Paste()); AddAssert("are two objects", () => EditorBeatmap.HitObjects.Count == 2); AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000); + + AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); + AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); } [Test] diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs new file mode 100644 index 0000000000..0b1617b6a6 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClock.cs @@ -0,0 +1,87 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit.Components; +using osuTK; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public class TestSceneEditorClock : EditorClockTestScene + { + public TestSceneEditorClock() + { + Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new TimeInfoContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 100) + }, + new PlaybackControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 100) + } + } + }); + } + + [BackgroundDependencyLoader] + private void load() + { + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + // ensure that music controller does not change this beatmap due to it + // completing naturally as part of the test. + Beatmap.Disabled = true; + } + + [Test] + public void TestStopAtTrackEnd() + { + AddStep("reset clock", () => Clock.Seek(0)); + + AddStep("start clock", Clock.Start); + AddAssert("clock running", () => Clock.IsRunning); + + AddStep("seek near end", () => Clock.Seek(Clock.TrackLength - 250)); + AddUntilStep("clock stops", () => !Clock.IsRunning); + + AddAssert("clock stopped at end", () => Clock.CurrentTime == Clock.TrackLength); + + AddStep("start clock again", Clock.Start); + AddAssert("clock looped to start", () => Clock.IsRunning && Clock.CurrentTime < 500); + } + + [Test] + public void TestWrapWhenStoppedAtTrackEnd() + { + AddStep("reset clock", () => Clock.Seek(0)); + + AddStep("stop clock", Clock.Stop); + AddAssert("clock stopped", () => !Clock.IsRunning); + + AddStep("seek exactly to end", () => Clock.Seek(Clock.TrackLength)); + AddAssert("clock stopped at end", () => Clock.CurrentTime == Clock.TrackLength); + + AddStep("start clock again", Clock.Start); + AddAssert("clock looped to start", () => Clock.IsRunning && Clock.CurrentTime < 500); + } + + protected override void Dispose(bool isDisposing) + { + Beatmap.Disabled = false; + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorQuickDelete.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorQuickDelete.cs deleted file mode 100644 index 9efd299fba..0000000000 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorQuickDelete.cs +++ /dev/null @@ -1,88 +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.Linq; -using NUnit.Framework; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; -using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; -using osu.Game.Tests.Beatmaps; -using osu.Game.Screens.Edit.Compose.Components; -using osuTK; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.Editing -{ - public class TestSceneEditorQuickDelete : EditorTestScene - { - protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); - - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); - - private BlueprintContainer blueprintContainer - => Editor.ChildrenOfType().First(); - - [Test] - public void TestQuickDeleteRemovesObject() - { - var addedObject = new HitCircle { StartTime = 1000 }; - - AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); - - AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); - - AddStep("move mouse to object", () => - { - var pos = blueprintContainer.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre; - InputManager.MoveMouseTo(pos); - }); - AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); - AddStep("right click", () => InputManager.Click(MouseButton.Right)); - AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); - - AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); - } - - [Test] - public void TestQuickDeleteRemovesSliderControlPoint() - { - Slider slider = new Slider { StartTime = 1000 }; - - PathControlPoint[] points = - { - new PathControlPoint(), - new PathControlPoint(new Vector2(50, 0)), - new PathControlPoint(new Vector2(100, 0)) - }; - - AddStep("add slider", () => - { - slider.Path = new SliderPath(points); - EditorBeatmap.Add(slider); - }); - - AddStep("select added slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); - - AddStep("move mouse to controlpoint", () => - { - var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; - InputManager.MoveMouseTo(pos); - }); - AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); - - AddStep("right click", () => InputManager.Click(MouseButton.Right)); - AddAssert("slider has 2 points", () => slider.Path.ControlPoints.Count == 2); - - // second click should nuke the object completely. - AddStep("right click", () => InputManager.Click(MouseButton.Right)); - AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); - - AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); - } - } -} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs new file mode 100644 index 0000000000..96ce418851 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeeking.cs @@ -0,0 +1,79 @@ +// 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.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorSeeking : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = base.CreateBeatmap(ruleset); + + beatmap.BeatmapInfo.BeatDivisor = 1; + + beatmap.ControlPointInfo = new ControlPointInfo(); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }); + beatmap.ControlPointInfo.Add(2000, new TimingControlPoint { BeatLength = 500 }); + + return beatmap; + } + + [Test] + public void TestSnappedSeeking() + { + AddStep("seek to 0", () => EditorClock.Seek(0)); + AddAssert("time is 0", () => EditorClock.CurrentTime == 0); + + pressAndCheckTime(Key.Right, 1000); + pressAndCheckTime(Key.Right, 2000); + pressAndCheckTime(Key.Right, 2500); + pressAndCheckTime(Key.Right, 3000); + + pressAndCheckTime(Key.Left, 2500); + pressAndCheckTime(Key.Left, 2000); + pressAndCheckTime(Key.Left, 1000); + } + + [Test] + public void TestSnappedSeekingAfterControlPointChange() + { + AddStep("seek to 0", () => EditorClock.Seek(0)); + AddAssert("time is 0", () => EditorClock.CurrentTime == 0); + + pressAndCheckTime(Key.Right, 1000); + pressAndCheckTime(Key.Right, 2000); + pressAndCheckTime(Key.Right, 2500); + pressAndCheckTime(Key.Right, 3000); + + AddStep("remove 2nd timing point", () => + { + EditorBeatmap.BeginChange(); + var group = EditorBeatmap.ControlPointInfo.GroupAt(2000); + EditorBeatmap.ControlPointInfo.RemoveGroup(group); + EditorBeatmap.EndChange(); + }); + + pressAndCheckTime(Key.Left, 2000); + pressAndCheckTime(Key.Left, 1000); + + pressAndCheckTime(Key.Right, 2000); + pressAndCheckTime(Key.Right, 3000); + } + + private void pressAndCheckTime(Key key, double expectedTime) + { + AddStep($"press {key}", () => InputManager.Key(key)); + AddUntilStep($"time is {expectedTime}", () => Precision.AlmostEquals(expectedTime, EditorClock.CurrentTime, 1)); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs new file mode 100644 index 0000000000..4c4a87972f --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSelection.cs @@ -0,0 +1,268 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Tests.Beatmaps; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorSelection : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private EditorBlueprintContainer blueprintContainer + => Editor.ChildrenOfType().First(); + + private void moveMouseToObject(Func targetFunc) + { + AddStep("move mouse to object", () => + { + var pos = blueprintContainer.SelectionBlueprints + .First(s => s.Item == targetFunc()) + .ChildrenOfType() + .First().ScreenSpaceDrawQuad.Centre; + + InputManager.MoveMouseTo(pos); + }); + } + + [Test] + public void TestNudgeSelection() + { + HitCircle[] addedObjects = null; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + })); + + AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + + AddStep("nudge forwards", () => InputManager.Key(Key.K)); + AddAssert("objects moved forwards in time", () => addedObjects[0].StartTime > 100); + + AddStep("nudge backwards", () => InputManager.Key(Key.J)); + AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100); + } + + [Test] + public void TestBasicSelect() + { + var addedObject = new HitCircle { StartTime = 100 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + moveMouseToObject(() => addedObject); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + + var addedObject2 = new HitCircle + { + StartTime = 100, + Position = new Vector2(100), + }; + + AddStep("add one more hitobject", () => EditorBeatmap.Add(addedObject2)); + AddAssert("selection unchanged", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + + moveMouseToObject(() => addedObject2); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject2); + } + + [Test] + public void TestMultiSelect() + { + var addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObjects[0]); + + AddStep("hold control", () => InputManager.PressKey(Key.ControlLeft)); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second", () => InputManager.Click(MouseButton.Left)); + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1])); + + moveMouseToObject(() => addedObjects[2]); + AddStep("click third", () => InputManager.Click(MouseButton.Left)); + AddAssert("3 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 3 && EditorBeatmap.SelectedHitObjects.Contains(addedObjects[2])); + + moveMouseToObject(() => addedObjects[1]); + AddStep("click second", () => InputManager.Click(MouseButton.Left)); + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && !EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1])); + } + + [TestCase(false)] + [TestCase(true)] + public void TestMultiSelectFromDrag(bool alreadySelectedBeforeDrag) + { + HitCircle[] addedObjects = null; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects = new[] + { + new HitCircle { StartTime = 100 }, + new HitCircle { StartTime = 200, Position = new Vector2(100) }, + new HitCircle { StartTime = 300, Position = new Vector2(200) }, + new HitCircle { StartTime = 400, Position = new Vector2(300) }, + })); + + moveMouseToObject(() => addedObjects[0]); + AddStep("click first", () => InputManager.Click(MouseButton.Left)); + + AddStep("hold control", () => InputManager.PressKey(Key.ControlLeft)); + + moveMouseToObject(() => addedObjects[1]); + + if (alreadySelectedBeforeDrag) + AddStep("click second", () => InputManager.Click(MouseButton.Left)); + + AddStep("mouse down on second", () => InputManager.PressButton(MouseButton.Left)); + + AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1])); + + AddStep("drag to centre", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre)); + + AddAssert("positions changed", () => addedObjects[0].Position != Vector2.Zero && addedObjects[1].Position != new Vector2(50)); + + AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft)); + AddStep("mouse up", () => InputManager.ReleaseButton(MouseButton.Left)); + } + + [Test] + public void TestBasicDeselect() + { + var addedObject = new HitCircle { StartTime = 100 }; + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + moveMouseToObject(() => addedObject); + AddStep("left click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject); + + AddStep("click away", () => + { + InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("selection lost", () => EditorBeatmap.SelectedHitObjects.Count == 0); + } + + [Test] + public void TestQuickDeleteRemovesObjectInPlacement() + { + var addedObject = new HitCircle + { + StartTime = 0, + Position = OsuPlayfield.BASE_SIZE * 0.5f + }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("enter placement mode", () => InputManager.PressKey(Key.Number2)); + + moveMouseToObject(() => addedObject); + + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); + } + + [Test] + public void TestQuickDeleteRemovesObjectInSelection() + { + var addedObject = new HitCircle + { + StartTime = 0, + Position = OsuPlayfield.BASE_SIZE * 0.5f + }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + moveMouseToObject(() => addedObject); + + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); + } + + [Test] + public void TestQuickDeleteRemovesSliderControlPoint() + { + Slider slider = null; + + PathControlPoint[] points = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(50, 0)), + new PathControlPoint(new Vector2(100, 0)) + }; + + AddStep("add slider", () => + { + slider = new Slider + { + StartTime = 1000, + Path = new SliderPath(points) + }; + + EditorBeatmap.Add(slider); + }); + + AddStep("select added slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + + AddStep("move mouse to controlpoint", () => + { + var pos = blueprintContainer.ChildrenOfType().ElementAt(1).ScreenSpaceDrawQuad.Centre; + InputManager.MoveMouseTo(pos); + }); + AddStep("hold shift", () => InputManager.PressKey(Key.ShiftLeft)); + + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + AddAssert("slider has 2 points", () => slider.Path.ControlPoints.Count == 2); + + AddStep("right click", () => InputManager.Click(MouseButton.Right)); + + // second click should nuke the object completely. + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); + + AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft)); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs index 94a9fd7b35..da0c83bb11 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.Timelines.Summary; using osuTK; @@ -16,18 +15,28 @@ namespace osu.Game.Tests.Visual.Editing public class TestSceneEditorSummaryTimeline : EditorClockTestScene { [Cached(typeof(EditorBeatmap))] - private readonly EditorBeatmap editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + private readonly EditorBeatmap editorBeatmap; - [BackgroundDependencyLoader] - private void load() + public TestSceneEditorSummaryTimeline() { - Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + editorBeatmap = new EditorBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + } - Add(new SummaryTimeline + protected override void LoadComplete() + { + base.LoadComplete(); + + AddStep("create timeline", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(500, 50) + // required for track + Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); + + Add(new SummaryTimeline + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 50) + }); }); } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs index 35f394fe1d..e6fad33a51 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs @@ -21,7 +21,7 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestDisallowZeroDurationObjects() { - DragBar dragBar; + DragArea dragArea; AddStep("add spinner", () => { @@ -29,7 +29,7 @@ namespace osu.Game.Tests.Visual.Editing EditorBeatmap.Add(new Spinner { Position = new Vector2(256, 256), - StartTime = 150, + StartTime = 2700, Duration = 500 }); }); @@ -37,8 +37,8 @@ namespace osu.Game.Tests.Visual.Editing AddStep("hold down drag bar", () => { // distinguishes between the actual drag bar and its "underlay shadow". - dragBar = this.ChildrenOfType().Single(bar => bar.HandlePositionalInput); - InputManager.MoveMouseTo(dragBar); + dragArea = this.ChildrenOfType().Single(bar => bar.HandlePositionalInput); + InputManager.MoveMouseTo(dragArea); InputManager.PressButton(MouseButton.Left); }); diff --git a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs index d6db171cf0..4aed445d9d 100644 --- a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs +++ b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs @@ -53,17 +53,21 @@ namespace osu.Game.Tests.Visual.Editing new AudioVisualiser(), } }, - TimelineArea = new TimelineArea + TimelineArea = new TimelineArea(CreateTestComponent()) { - Child = CreateTestComponent(), Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Size = new Vector2(0.8f, 100), } }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + Clock.Seek(2500); + } + public abstract Drawable CreateTestComponent(); private class AudioVisualiser : CompositeDrawable @@ -110,8 +114,6 @@ namespace osu.Game.Tests.Visual.Editing [Resolved] private EditorClock editorClock { get; set; } - private bool started; - public StartStopButton() { BackgroundColour = Color4.SlateGray; @@ -123,18 +125,17 @@ namespace osu.Game.Tests.Visual.Editing private void onClick() { - if (started) - { + if (editorClock.IsRunning) editorClock.Stop(); - Text = "Start"; - } else - { editorClock.Start(); - Text = "Stop"; - } + } - started = !started; + protected override void Update() + { + base.Update(); + + Text = editorClock.IsRunning ? "Stop" : "Start"; } } } diff --git a/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs b/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs new file mode 100644 index 0000000000..6de9d7c478 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/SkinnableHUDComponentTestScene.cs @@ -0,0 +1,33 @@ +// 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.Graphics; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public abstract class SkinnableHUDComponentTestScene : SkinnableTestScene + { + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + + [SetUp] + public void SetUp() => Schedule(() => + { + SetContents(skin => + { + var implementation = skin != null + ? CreateLegacyImplementation() + : CreateDefaultImplementation(); + + implementation.Anchor = Anchor.Centre; + implementation.Origin = Anchor.Centre; + return implementation; + }); + }); + + protected abstract Drawable CreateDefaultImplementation(); + protected abstract Drawable CreateLegacyImplementation(); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index e198a8504b..e47c782bca 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void seekToBreak(int breakIndex) { AddStep($"seek to break {breakIndex}", () => Player.GameplayClockContainer.Seek(destBreak().StartTime)); - AddUntilStep("wait for seek to complete", () => Player.HUDOverlay.Progress.ReferenceClock.CurrentTime >= destBreak().StartTime); + AddUntilStep("wait for seek to complete", () => Player.DrawableRuleset.FrameStableClock.CurrentTime >= destBreak().StartTime); BreakPeriod destBreak() => Beatmap.Value.Beatmap.Breaks.ElementAt(breakIndex); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs new file mode 100644 index 0000000000..cc53e50884 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics.Containers; +using osu.Framework.Lists; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Extensions; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Skinning.Legacy; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; +using osu.Game.Storyboards; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneBeatmapSkinFallbacks : OsuPlayerTestScene + { + private ISkin currentBeatmapSkin; + + [Resolved] + private SkinManager skinManager { get; set; } + + [Cached] + private ScoreProcessor scoreProcessor = new ScoreProcessor(); + + [Cached(typeof(HealthProcessor))] + private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + + protected override bool HasCustomSteps => true; + + [Test] + public void TestEmptyLegacyBeatmapSkinFallsBack() + { + CreateSkinTest(SkinInfo.Default, () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null)); + AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value)); + } + + protected void CreateSkinTest(SkinInfo gameCurrentSkin, Func getBeatmapSkin) + { + CreateTest(() => + { + AddStep("setup skins", () => + { + skinManager.CurrentSkinInfo.Value = gameCurrentSkin; + currentBeatmapSkin = getBeatmapSkin(); + }); + }); + } + + protected bool AssertComponentsFromExpectedSource(SkinnableTarget target, ISkin expectedSource) + { + var actualComponentsContainer = Player.ChildrenOfType().First(s => s.Target == target) + .ChildrenOfType().SingleOrDefault(); + + if (actualComponentsContainer == null) + return false; + + var actualInfo = actualComponentsContainer.CreateSkinnableInfo(); + + var expectedComponentsContainer = (SkinnableTargetComponentsContainer)expectedSource.GetDrawableComponent(new SkinnableTargetComponent(target)); + if (expectedComponentsContainer == null) + return false; + + var expectedComponentsAdjustmentContainer = new Container + { + Position = actualComponentsContainer.Parent.ToSpaceOfOtherDrawable(actualComponentsContainer.DrawPosition, Content), + Size = actualComponentsContainer.DrawSize, + Child = expectedComponentsContainer, + }; + + Add(expectedComponentsAdjustmentContainer); + expectedComponentsAdjustmentContainer.UpdateSubTree(); + var expectedInfo = expectedComponentsContainer.CreateSkinnableInfo(); + Remove(expectedComponentsAdjustmentContainer); + + return almostEqual(actualInfo, expectedInfo); + + static bool almostEqual(SkinnableInfo info, SkinnableInfo other) => + other != null + && info.Type == other.Type + && info.Anchor == other.Anchor + && info.Origin == other.Origin + && Precision.AlmostEquals(info.Position, other.Position) + && Precision.AlmostEquals(info.Scale, other.Scale) + && Precision.AlmostEquals(info.Rotation, other.Rotation) + && info.Children.SequenceEqual(other.Children, new FuncEqualityComparer(almostEqual)); + } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + => new CustomSkinWorkingBeatmap(beatmap, storyboard, Clock, Audio, currentBeatmapSkin); + + protected override Ruleset CreatePlayerRuleset() => new TestOsuRuleset(); + + private class CustomSkinWorkingBeatmap : ClockBackedTestWorkingBeatmap + { + private readonly ISkin beatmapSkin; + + public CustomSkinWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, AudioManager audio, ISkin beatmapSkin) + : base(beatmap, storyboard, referenceClock, audio) + { + this.beatmapSkin = beatmapSkin; + } + + protected override ISkin GetSkin() => beatmapSkin; + } + + private class TestOsuRuleset : OsuRuleset + { + public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new TestOsuLegacySkinTransformer(source); + + private class TestOsuLegacySkinTransformer : OsuLegacySkinTransformer + { + public TestOsuLegacySkinTransformer(ISkinSource source) + : base(source) + { + } + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs deleted file mode 100644 index d0c2fb5064..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs +++ /dev/null @@ -1,47 +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.Collections.Generic; -using System.Linq; -using NUnit.Framework; -using osu.Framework.Testing; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Play.HUD; - -namespace osu.Game.Tests.Visual.Gameplay -{ - public class TestSceneComboCounter : SkinnableTestScene - { - private IEnumerable comboCounters => CreatedDrawables.OfType(); - - protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("Create combo counters", () => SetContents(() => - { - var comboCounter = new SkinnableComboCounter(); - comboCounter.Current.Value = 1; - return comboCounter; - })); - } - - [Test] - public void TestComboCounterIncrementing() - { - AddRepeatStep("increase combo", () => - { - foreach (var counter in comboCounters) - counter.Current.Value++; - }, 10); - - AddStep("reset combo", () => - { - foreach (var counter in comboCounters) - counter.Current.Value = 0; - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs index 9931ee4a45..75a5eec6f7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs @@ -90,6 +90,20 @@ namespace osu.Game.Tests.Visual.Gameplay assertChildPosition(5); } + [TestCase("pooled")] + [TestCase("non-pooled")] + public void TestLifetimeRecomputedWhenTimeRangeChanges(string pooled) + { + var beatmap = createBeatmap(_ => pooled == "pooled" ? new TestPooledHitObject() : new TestHitObject()); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); + createTest(beatmap); + + assertDead(3); + + AddStep("increase time range", () => drawableRuleset.TimeRange.Value = 3 * time_range); + assertPosition(3, 1); + } + [Test] public void TestRelativeBeatLengthScaleSingleTimingPoint() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs index 9501026edc..52bedc328d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs @@ -29,8 +29,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("allow skin lookup", () => storyboard.UseSkinSprites = false); - AddStep("create sprites", () => SetContents( - () => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); assertSpritesFromSkin(false); } @@ -42,8 +41,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); - AddStep("create sprites", () => SetContents( - () => createSprite(lookup_name, Anchor.Centre, Vector2.Zero))); + AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.Centre, Vector2.Zero))); assertSpritesFromSkin(true); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs index 1c55595c97..c335f7c99e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; @@ -20,24 +23,28 @@ namespace osu.Game.Tests.Visual.Gameplay [Resolved] private OsuConfigManager config { get; set; } - [SetUpSteps] - public void SetUpSteps() + private void create(HealthProcessor healthProcessor) { AddStep("create layer", () => { - Child = layer = new FailingLayer(); - layer.BindHealthProcessor(new DrainingHealthProcessor(1)); + Child = new HealthProcessorContainer(healthProcessor) + { + RelativeSizeAxes = Axes.Both, + Child = layer = new FailingLayer() + }; + layer.ShowHealth.BindTo(showHealth); }); AddStep("show health", () => showHealth.Value = true); - AddStep("enable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); - AddUntilStep("layer is visible", () => layer.IsPresent); + AddStep("enable layer", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, true)); } [Test] public void TestLayerFading() { + create(new DrainingHealthProcessor(0)); + AddSliderStep("current health", 0.0, 1.0, 1.0, val => { if (layer != null) @@ -45,15 +52,17 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddStep("set health to 0.10", () => layer.Current.Value = 0.1); - AddUntilStep("layer fade is visible", () => layer.Child.Alpha > 0.1f); + AddUntilStep("layer fade is visible", () => layer.ChildrenOfType().First().Alpha > 0.1f); AddStep("set health to 1", () => layer.Current.Value = 1f); - AddUntilStep("layer fade is invisible", () => !layer.Child.IsPresent); + AddUntilStep("layer fade is invisible", () => !layer.ChildrenOfType().First().IsPresent); } [Test] public void TestLayerDisabledViaConfig() { - AddStep("disable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + create(new DrainingHealthProcessor(0)); + AddUntilStep("layer is visible", () => layer.IsPresent); + AddStep("disable layer", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); AddStep("set health to 0.10", () => layer.Current.Value = 0.1); AddUntilStep("layer is not visible", () => !layer.IsPresent); } @@ -61,7 +70,8 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestLayerVisibilityWithAccumulatingProcessor() { - AddStep("bind accumulating processor", () => layer.BindHealthProcessor(new AccumulatingHealthProcessor(1))); + create(new AccumulatingHealthProcessor(1)); + AddUntilStep("layer is not visible", () => !layer.IsPresent); AddStep("set health to 0.10", () => layer.Current.Value = 0.1); AddUntilStep("layer is not visible", () => !layer.IsPresent); } @@ -69,7 +79,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestLayerVisibilityWithDrainingProcessor() { - AddStep("bind accumulating processor", () => layer.BindHealthProcessor(new DrainingHealthProcessor(1))); + create(new DrainingHealthProcessor(0)); AddStep("set health to 0.10", () => layer.Current.Value = 0.1); AddWaitStep("wait for potential fade", 10); AddAssert("layer is still visible", () => layer.IsPresent); @@ -78,23 +88,36 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestLayerVisibilityWithDifferentOptions() { + create(new DrainingHealthProcessor(0)); + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); AddStep("don't show health", () => showHealth.Value = false); - AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddStep("disable FadePlayfieldWhenHealthLow", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); AddUntilStep("layer fade is invisible", () => !layer.IsPresent); AddStep("don't show health", () => showHealth.Value = false); - AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, true)); AddUntilStep("layer fade is invisible", () => !layer.IsPresent); AddStep("show health", () => showHealth.Value = true); - AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddStep("disable FadePlayfieldWhenHealthLow", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); AddUntilStep("layer fade is invisible", () => !layer.IsPresent); AddStep("show health", () => showHealth.Value = true); - AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, true)); AddUntilStep("layer fade is visible", () => layer.IsPresent); } + + private class HealthProcessorContainer : Container + { + [Cached(typeof(HealthProcessor))] + private readonly HealthProcessor healthProcessor; + + public HealthProcessorContainer(HealthProcessor healthProcessor) + { + this.healthProcessor = healthProcessor; + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index f9914e0193..b7e92a79a0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osuTK.Input; @@ -19,6 +20,12 @@ namespace osu.Game.Tests.Visual.Gameplay { private HUDOverlay hudOverlay; + [Cached] + private ScoreProcessor scoreProcessor = new ScoreProcessor(); + + [Cached(typeof(HealthProcessor))] + private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + // best way to check without exposing. private Drawable hideTarget => hudOverlay.KeyCounter; private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); @@ -31,9 +38,9 @@ namespace osu.Game.Tests.Visual.Gameplay { createNew(); - AddRepeatStep("increase combo", () => { hudOverlay.ComboCounter.Current.Value++; }, 10); + AddRepeatStep("increase combo", () => { scoreProcessor.Combo.Value++; }, 10); - AddStep("reset combo", () => { hudOverlay.ComboCounter.Current.Value = 0; }); + AddStep("reset combo", () => { scoreProcessor.Combo.Value = 0; }); } [Test] @@ -81,7 +88,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("get original config value", () => originalConfigValue = config.Get(OsuSetting.HUDVisibilityMode)); - AddStep("set hud to never show", () => config.Set(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Never)); + AddStep("set hud to never show", () => config.SetValue(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Never)); AddUntilStep("wait for fade", () => !hideTarget.IsPresent); @@ -91,7 +98,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("stop trigering", () => InputManager.ReleaseKey(Key.ControlLeft)); AddUntilStep("wait for fade", () => !hideTarget.IsPresent); - AddStep("set original config value", () => config.Set(OsuSetting.HUDVisibilityMode, originalConfigValue)); + AddStep("set original config value", () => config.SetValue(OsuSetting.HUDVisibilityMode, originalConfigValue)); } [Test] @@ -120,7 +127,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("set keycounter visible false", () => { - config.Set(OsuSetting.KeyOverlay, false); + config.SetValue(OsuSetting.KeyOverlay, false); hudOverlay.KeyCounter.AlwaysVisible.Value = false; }); @@ -132,19 +139,19 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("hidetarget is visible", () => hideTarget.IsPresent); AddAssert("key counters still hidden", () => !keyCounterFlow.IsPresent); - AddStep("return value", () => config.Set(OsuSetting.KeyOverlay, keyCounterVisibleValue)); + AddStep("return value", () => config.SetValue(OsuSetting.KeyOverlay, keyCounterVisibleValue)); } private void createNew(Action action = null) { AddStep("create overlay", () => { - hudOverlay = new HUDOverlay(null, null, null, Array.Empty()); + hudOverlay = new HUDOverlay(null, Array.Empty()); // Add any key just to display the key counter visually. hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); - hudOverlay.ComboCounter.Current.Value = 1; + scoreProcessor.Combo.Value = 1; action?.Invoke(hudOverlay); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index 1021ac3760..2c5443fe08 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -1,32 +1,39 @@ // 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.Diagnostics.CodeAnalysis; using NUnit.Framework; -using osu.Game.Rulesets.Judgements; -using osu.Framework.Utils; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Catch.Scoring; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Screens.Play.HUD.HitErrorMeters; namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneHitErrorMeter : OsuTestScene { - private BarHitErrorMeter barMeter; - private BarHitErrorMeter barMeter2; - private BarHitErrorMeter barMeter3; - private ColourHitErrorMeter colourMeter; - private ColourHitErrorMeter colourMeter2; - private ColourHitErrorMeter colourMeter3; - private HitWindows hitWindows; + [Cached] + private ScoreProcessor scoreProcessor = new ScoreProcessor(); + + [Cached(typeof(DrawableRuleset))] + private TestDrawableRuleset drawableRuleset = new TestDrawableRuleset(); public TestSceneHitErrorMeter() { @@ -34,8 +41,8 @@ namespace osu.Game.Tests.Visual.Gameplay AddRepeatStep("New random judgement", () => newJudgement(), 40); - AddRepeatStep("New max negative", () => newJudgement(-hitWindows.WindowFor(HitResult.Meh)), 20); - AddRepeatStep("New max positive", () => newJudgement(hitWindows.WindowFor(HitResult.Meh)), 20); + AddRepeatStep("New max negative", () => newJudgement(-drawableRuleset.HitWindows.WindowFor(HitResult.Meh)), 20); + AddRepeatStep("New max positive", () => newJudgement(drawableRuleset.HitWindows.WindowFor(HitResult.Meh)), 20); AddStep("New fixed judgement (50ms)", () => newJudgement(50)); AddStep("Judgement barrage", () => @@ -85,10 +92,10 @@ namespace osu.Game.Tests.Visual.Gameplay private void recreateDisplay(HitWindows hitWindows, float overallDifficulty) { - this.hitWindows = hitWindows; - hitWindows?.SetDifficulty(overallDifficulty); + drawableRuleset.HitWindows = hitWindows; + Clear(); Add(new FillFlowContainer @@ -105,40 +112,40 @@ namespace osu.Game.Tests.Visual.Gameplay } }); - Add(barMeter = new BarHitErrorMeter(hitWindows, true) + Add(new BarHitErrorMeter { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, }); - Add(barMeter2 = new BarHitErrorMeter(hitWindows, false) + Add(new BarHitErrorMeter { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }); - Add(barMeter3 = new BarHitErrorMeter(hitWindows, true) + Add(new BarHitErrorMeter { Anchor = Anchor.BottomCentre, Origin = Anchor.CentreLeft, Rotation = 270, }); - Add(colourMeter = new ColourHitErrorMeter(hitWindows) + Add(new ColourHitErrorMeter { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Margin = new MarginPadding { Right = 50 } }); - Add(colourMeter2 = new ColourHitErrorMeter(hitWindows) + Add(new ColourHitErrorMeter { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Margin = new MarginPadding { Left = 50 } }); - Add(colourMeter3 = new ColourHitErrorMeter(hitWindows) + Add(new ColourHitErrorMeter { Anchor = Anchor.BottomCentre, Origin = Anchor.CentreLeft, @@ -149,18 +156,47 @@ namespace osu.Game.Tests.Visual.Gameplay private void newJudgement(double offset = 0) { - var judgement = new JudgementResult(new HitObject(), new Judgement()) + scoreProcessor.ApplyResult(new JudgementResult(new HitCircle { HitWindows = drawableRuleset.HitWindows }, new Judgement()) { TimeOffset = offset == 0 ? RNG.Next(-150, 150) : offset, Type = HitResult.Perfect, - }; + }); + } - barMeter.OnNewJudgement(judgement); - barMeter2.OnNewJudgement(judgement); - barMeter3.OnNewJudgement(judgement); - colourMeter.OnNewJudgement(judgement); - colourMeter2.OnNewJudgement(judgement); - colourMeter3.OnNewJudgement(judgement); + [SuppressMessage("ReSharper", "UnassignedGetOnlyAutoProperty")] + private class TestDrawableRuleset : DrawableRuleset + { + public HitWindows HitWindows; + + public override IEnumerable Objects => new[] { new HitCircle { HitWindows = HitWindows } }; + + public override event Action NewResult; + public override event Action RevertResult; + + public override Playfield Playfield { get; } + public override Container Overlays { get; } + public override Container FrameStableComponents { get; } + public override IFrameStableClock FrameStableClock { get; } + public override IReadOnlyList Mods { get; } + + public override double GameplayStartTime { get; } + public override GameplayCursorContainer Cursor { get; } + + public TestDrawableRuleset() + : base(new OsuRuleset()) + { + // won't compile without this. + NewResult?.Invoke(null); + RevertResult?.Invoke(null); + } + + public override void SetReplayScore(Score replayScore) => throw new NotImplementedException(); + + public override void SetRecordTarget(Score score) => throw new NotImplementedException(); + + public override void RequestResume(Action continueResume) => throw new NotImplementedException(); + + public override void CancelResume() => throw new NotImplementedException(); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index 563d6be0da..f5f17a0bc1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -46,11 +46,12 @@ namespace osu.Game.Tests.Visual.Gameplay [TestCase(0, 0)] [TestCase(-1000, -1000)] [TestCase(-10000, -10000)] - public void TestStoryboardProducesCorrectStartTime(double firstStoryboardEvent, double expectedStartTime) + public void TestStoryboardProducesCorrectStartTimeSimpleAlpha(double firstStoryboardEvent, double expectedStartTime) { var storyboard = new Storyboard(); var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); + sprite.TimelineGroup.Alpha.Add(Easing.None, firstStoryboardEvent, firstStoryboardEvent + 500, 0, 1); storyboard.GetLayer("Background").Add(sprite); @@ -64,6 +65,43 @@ namespace osu.Game.Tests.Visual.Gameplay }); } + [TestCase(1000, 0, false)] + [TestCase(0, 0, false)] + [TestCase(-1000, -1000, false)] + [TestCase(-10000, -10000, false)] + [TestCase(1000, 0, true)] + [TestCase(0, 0, true)] + [TestCase(-1000, -1000, true)] + [TestCase(-10000, -10000, true)] + public void TestStoryboardProducesCorrectStartTimeFadeInAfterOtherEvents(double firstStoryboardEvent, double expectedStartTime, bool addEventToLoop) + { + var storyboard = new Storyboard(); + + var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); + + // these should be ignored as we have an alpha visibility blocker proceeding this command. + sprite.TimelineGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); + var loopGroup = sprite.AddLoop(-20000, 50); + loopGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); + + var target = addEventToLoop ? loopGroup : sprite.TimelineGroup; + target.Alpha.Add(Easing.None, firstStoryboardEvent, firstStoryboardEvent + 500, 0, 1); + + // these should be ignored due to being in the future. + sprite.TimelineGroup.Alpha.Add(Easing.None, 18000, 20000, 0, 1); + loopGroup.Alpha.Add(Easing.None, 18000, 20000, 0, 1); + + storyboard.GetLayer("Background").Add(sprite); + + loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo), storyboard); + + AddAssert($"first frame is {expectedStartTime}", () => + { + Debug.Assert(player.FirstFrameClockTime != null); + return Precision.AlmostEquals(player.FirstFrameClockTime.Value, expectedStartTime, lenience_ms); + }); + } + private void loadPlayerWithBeatmap(IBeatmap beatmap, Storyboard storyboard = null) { AddStep("create player", () => @@ -92,9 +130,9 @@ namespace osu.Game.Tests.Visual.Gameplay public double GameplayClockTime => GameplayClockContainer.GameplayClock.CurrentTime; - protected override void UpdateAfterChildren() + protected override void Update() { - base.UpdateAfterChildren(); + base.Update(); if (!FirstFrameClockTime.HasValue) { diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs index e43e5ba3ce..49c1163c6c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.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.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,21 +9,17 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Storyboards; +using osu.Game.Tests.Beatmaps; +using osuTK; namespace osu.Game.Tests.Visual.Gameplay { [HeadlessTest] // we alter unsafe properties on the game host to test inactive window state. public class TestScenePauseWhenInactive : OsuPlayerTestScene { - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) - { - var beatmap = (Beatmap)base.CreateBeatmap(ruleset); - - beatmap.HitObjects.RemoveAll(h => h.StartTime < 30000); - - return beatmap; - } - [Resolved] private GameHost host { get; set; } @@ -33,10 +30,57 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("resume player", () => Player.GameplayClockContainer.Start()); AddAssert("ensure not paused", () => !Player.GameplayClockContainer.IsPaused.Value); + + AddStep("progress time to gameplay", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.GameplayStartTime)); + AddUntilStep("wait for pause", () => Player.GameplayClockContainer.IsPaused.Value); + } + + /// + /// Tests that if a pause from focus lose is performed while in pause cooldown, + /// the player will still pause after the cooldown is finished. + /// + [Test] + public void TestPauseWhileInCooldown() + { + AddStep("move cursor outside", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.TopLeft - new Vector2(10))); + + AddStep("resume player", () => Player.GameplayClockContainer.Start()); + AddStep("skip to gameplay", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.GameplayStartTime)); + + AddStep("set inactive", () => ((Bindable)host.IsActive).Value = false); + AddUntilStep("wait for pause", () => Player.GameplayClockContainer.IsPaused.Value); + + AddStep("set active", () => ((Bindable)host.IsActive).Value = true); + + AddStep("resume player", () => Player.Resume()); + AddAssert("unpaused", () => !Player.GameplayClockContainer.IsPaused.Value); + + bool pauseCooldownActive = false; + + AddStep("set inactive again", () => + { + pauseCooldownActive = Player.PauseCooldownActive; + ((Bindable)host.IsActive).Value = false; + }); + AddAssert("pause cooldown active", () => pauseCooldownActive); AddUntilStep("wait for pause", () => Player.GameplayClockContainer.IsPaused.Value); - AddAssert("time of pause is after gameplay start time", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= Player.DrawableRuleset.GameplayStartTime); } protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(true, true, true); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + return new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 30000 }, + new HitCircle { StartTime = 35000 }, + }, + }; + } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + => new TestWorkingBeatmap(beatmap, storyboard, Audio); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 88fbf09ef4..8160a62991 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -25,6 +25,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; +using osu.Game.Utils; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay @@ -48,6 +49,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private readonly VolumeOverlay volumeOverlay; + [Cached(typeof(BatteryInfo))] + private readonly LocalBatteryInfo batteryInfo = new LocalBatteryInfo(); + private readonly ChangelogOverlay changelogOverlay; public TestScenePlayerLoader() @@ -84,13 +88,18 @@ namespace osu.Game.Tests.Visual.Gameplay { beforeLoadAction?.Invoke(); + prepareBeatmap(); + + LoadScreen(loader = new TestPlayerLoader(() => player = new TestPlayer(interactive, interactive))); + } + + private void prepareBeatmap() + { Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); Beatmap.Value.BeatmapInfo.EpilepsyWarning = epilepsyWarning; foreach (var mod in SelectedMods.Value.OfType()) mod.ApplyToTrack(Beatmap.Value.Track); - - LoadScreen(loader = new TestPlayerLoader(() => player = new TestPlayer(interactive, interactive))); } [Test] @@ -174,10 +183,13 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("load slow dummy beatmap", () => { - LoadScreen(loader = new TestPlayerLoader(() => slowPlayer = new SlowLoadPlayer(false, false))); - Scheduler.AddDelayed(() => slowPlayer.AllowLoad.Set(), 5000); + prepareBeatmap(); + slowPlayer = new SlowLoadPlayer(false, false); + LoadScreen(loader = new TestPlayerLoader(() => slowPlayer)); }); + AddStep("schedule slow load", () => Scheduler.AddDelayed(() => slowPlayer.AllowLoad.Set(), 5000)); + AddUntilStep("wait for player to be current", () => slowPlayer.IsCurrentScreen()); } @@ -288,6 +300,33 @@ namespace osu.Game.Tests.Visual.Gameplay } } + [TestCase(false, 1.0, false)] // not charging, above cutoff --> no warning + [TestCase(true, 0.1, false)] // charging, below cutoff --> no warning + [TestCase(false, 0.25, true)] // not charging, at cutoff --> warning + public void TestLowBatteryNotification(bool isCharging, double chargeLevel, bool shouldWarn) + { + AddStep("reset notification lock", () => sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce).Value = false); + + // set charge status and level + AddStep("load player", () => resetPlayer(false, () => + { + batteryInfo.SetCharging(isCharging); + batteryInfo.SetChargeLevel(chargeLevel); + })); + AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready); + AddAssert($"notification {(shouldWarn ? "triggered" : "not triggered")}", () => notificationOverlay.UnreadCount.Value == (shouldWarn ? 1 : 0)); + AddStep("click notification", () => + { + var scrollContainer = (OsuScrollContainer)notificationOverlay.Children.Last(); + var flowContainer = scrollContainer.Children.OfType>().First(); + var notification = flowContainer.First(); + + InputManager.MoveMouseTo(notification); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("wait for player load", () => player.IsLoaded); + } + [Test] public void TestEpilepsyWarningEarlyExit() { @@ -321,6 +360,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override string Name => string.Empty; public override string Acronym => string.Empty; public override double ScoreMultiplier => 1; + public override string Description => string.Empty; public bool Applied { get; private set; } @@ -348,5 +388,29 @@ namespace osu.Game.Tests.Visual.Gameplay throw new TimeoutException(); } } + + /// + /// Mutable dummy BatteryInfo class for + /// + /// + private class LocalBatteryInfo : BatteryInfo + { + private bool isCharging = true; + private double chargeLevel = 1; + + public override bool IsCharging => isCharging; + + public override double ChargeLevel => chargeLevel; + + public void SetCharging(bool value) + { + isCharging = value; + } + + public void SetChargeLevel(double value) + { + chargeLevel = value; + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index 802dbf2021..b38f7a998d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -118,8 +118,8 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestBasic() { AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("one frame recorded", () => replay.Frames.Count == 1); - AddAssert("position matches", () => playbackManager.ChildrenOfType().First().Position == recordingManager.ChildrenOfType().First().Position); + AddUntilStep("at least one frame recorded", () => replay.Frames.Count > 0); + AddUntilStep("position matches", () => playbackManager.ChildrenOfType().First().Position == recordingManager.ChildrenOfType().First().Position); } [Test] @@ -139,14 +139,16 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestLimitedFrameRate() { ScheduledDelegate moveFunction = null; + int initialFrameCount = 0; AddStep("lower rate", () => recorder.RecordFrameRate = 2); + AddStep("count frames", () => initialFrameCount = replay.Frames.Count); AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); AddStep("much move", () => moveFunction = Scheduler.AddDelayed(() => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)), 10, true)); AddWaitStep("move", 10); AddStep("stop move", () => moveFunction.Cancel()); - AddAssert("less than 10 frames recorded", () => replay.Frames.Count < 10); + AddAssert("less than 10 frames recorded", () => replay.Frames.Count - initialFrameCount < 10); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs new file mode 100644 index 0000000000..a0b27755b7 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -0,0 +1,45 @@ +// 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.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Skinning; +using osu.Game.Skinning.Editor; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinEditor : PlayerTestScene + { + private SkinEditor skinEditor; + + [Resolved] + private SkinManager skinManager { get; set; } + + protected override bool Autoplay => true; + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reload skin editor", () => + { + skinEditor?.Expire(); + Player.ScaleTo(SkinEditorOverlay.VISIBLE_TARGET_SCALE); + LoadComponentAsync(skinEditor = new SkinEditor(Player), Add); + }); + } + + [Test] + public void TestToggleEditor() + { + AddToggleStep("toggle editor visibility", visible => skinEditor.ToggleVisibility()); + } + + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs new file mode 100644 index 0000000000..58c89411c0 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorComponentsList.cs @@ -0,0 +1,26 @@ +// 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.Graphics; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Skinning.Editor; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinEditorComponentsList : SkinnableTestScene + { + [Test] + public void TestToggleEditor() + { + AddStep("show available components", () => SetContents(_ => new SkinComponentToolbox(300) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + })); + } + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs new file mode 100644 index 0000000000..856747ad19 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -0,0 +1,65 @@ +// 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.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Skinning.Editor; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinEditorMultipleSkins : SkinnableTestScene + { + [Cached] + private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(); + + [Cached(typeof(HealthProcessor))] + private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create editor overlay", () => + { + SetContents(_ => + { + var ruleset = new OsuRuleset(); + var mods = new[] { ruleset.GetAutoplayMod() }; + var working = CreateWorkingBeatmap(ruleset.RulesetInfo); + var beatmap = working.GetPlayableBeatmap(ruleset.RulesetInfo, mods); + + var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap, mods); + + var hudOverlay = new HUDOverlay(drawableRuleset, mods) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + + // Add any key just to display the key counter visually. + hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); + scoreProcessor.Combo.Value = 1; + + return new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + drawableRuleset, + hudOverlay, + new SkinEditor(hudOverlay), + } + }; + }); + }); + } + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs index 709929dcb0..80eb887894 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs @@ -1,49 +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.Collections.Generic; -using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Testing; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableAccuracyCounter : SkinnableTestScene + public class TestSceneSkinnableAccuracyCounter : SkinnableHUDComponentTestScene { - private IEnumerable accuracyCounters => CreatedDrawables.OfType(); + [Cached] + private ScoreProcessor scoreProcessor = new ScoreProcessor(); - protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + protected override Drawable CreateDefaultImplementation() => new DefaultAccuracyCounter(); + protected override Drawable CreateLegacyImplementation() => new LegacyAccuracyCounter(); [SetUpSteps] public void SetUpSteps() { - AddStep("Create combo counters", () => SetContents(() => - { - var accuracyCounter = new SkinnableAccuracyCounter(); - - accuracyCounter.Current.Value = 1; - - return accuracyCounter; - })); + AddStep("Set initial accuracy", () => scoreProcessor.Accuracy.Value = 1); } [Test] public void TestChangingAccuracy() { - AddStep(@"Reset all", delegate - { - foreach (var s in accuracyCounters) - s.Current.Value = 1; - }); + AddStep(@"Reset all", () => scoreProcessor.Accuracy.Value = 1); - AddStep(@"Hit! :D", delegate - { - foreach (var s in accuracyCounters) - s.Current.Value -= 0.023f; - }); + AddStep(@"Miss :(", () => scoreProcessor.Accuracy.Value -= 0.023); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs new file mode 100644 index 0000000000..1c5a05dd1d --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableComboCounter.cs @@ -0,0 +1,28 @@ +// 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.Graphics; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableComboCounter : SkinnableHUDComponentTestScene + { + [Cached] + private ScoreProcessor scoreProcessor = new ScoreProcessor(); + + protected override Drawable CreateDefaultImplementation() => new DefaultComboCounter(); + protected override Drawable CreateLegacyImplementation() => new LegacyComboCounter(); + + [Test] + public void TestComboCounterIncrementing() + { + AddRepeatStep("increase combo", () => scoreProcessor.Combo.Value++, 10); + + AddStep("reset combo", () => scoreProcessor.Combo.Value = 0); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs index 44142b69d7..7a6e2f54c2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs @@ -298,7 +298,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); } @@ -309,7 +309,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); } @@ -321,7 +321,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); - public Sample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index fec1610160..723e35ed55 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -14,6 +14,7 @@ using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osuTK.Input; @@ -23,6 +24,12 @@ namespace osu.Game.Tests.Visual.Gameplay { private HUDOverlay hudOverlay; + [Cached] + private ScoreProcessor scoreProcessor = new ScoreProcessor(); + + [Cached(typeof(HealthProcessor))] + private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + private IEnumerable hudOverlays => CreatedDrawables.OfType(); // best way to check without exposing. @@ -37,17 +44,9 @@ namespace osu.Game.Tests.Visual.Gameplay { createNew(); - AddRepeatStep("increase combo", () => - { - foreach (var hud in hudOverlays) - hud.ComboCounter.Current.Value++; - }, 10); + AddRepeatStep("increase combo", () => scoreProcessor.Combo.Value++, 10); - AddStep("reset combo", () => - { - foreach (var hud in hudOverlays) - hud.ComboCounter.Current.Value = 0; - }); + AddStep("reset combo", () => scoreProcessor.Combo.Value = 0); } [Test] @@ -78,15 +77,13 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("create overlay", () => { - SetContents(() => + SetContents(_ => { - hudOverlay = new HUDOverlay(null, null, null, Array.Empty()); + hudOverlay = new HUDOverlay(null, Array.Empty()); // Add any key just to display the key counter visually. hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); - hudOverlay.ComboCounter.Current.Value = 1; - action?.Invoke(hudOverlay); return hudOverlay; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs index 5bac8582d7..057798c922 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs @@ -1,35 +1,33 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Testing; -using osu.Game.Rulesets; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableHealthDisplay : SkinnableTestScene + public class TestSceneSkinnableHealthDisplay : SkinnableHUDComponentTestScene { - private IEnumerable healthDisplays => CreatedDrawables.OfType(); + [Cached(typeof(HealthProcessor))] + private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); - protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + protected override Drawable CreateDefaultImplementation() => new DefaultHealthDisplay(); + protected override Drawable CreateLegacyImplementation() => new LegacyHealthDisplay(); [SetUpSteps] public void SetUpSteps() { - AddStep("Create health displays", () => - { - SetContents(() => new SkinnableHealthDisplay()); - }); AddStep(@"Reset all", delegate { - foreach (var s in healthDisplays) - s.Current.Value = 1; + healthProcessor.Health.Value = 1; }); } @@ -38,23 +36,21 @@ namespace osu.Game.Tests.Visual.Gameplay { AddRepeatStep(@"decrease hp", delegate { - foreach (var healthDisplay in healthDisplays) - healthDisplay.Current.Value -= 0.08f; + healthProcessor.Health.Value -= 0.08f; }, 10); AddRepeatStep(@"increase hp without flash", delegate { - foreach (var healthDisplay in healthDisplays) - healthDisplay.Current.Value += 0.1f; + healthProcessor.Health.Value += 0.1f; }, 3); AddRepeatStep(@"increase hp with flash", delegate { - foreach (var healthDisplay in healthDisplays) + healthProcessor.Health.Value += 0.1f; + healthProcessor.ApplyResult(new JudgementResult(new HitCircle(), new OsuJudgement()) { - healthDisplay.Current.Value += 0.1f; - healthDisplay.Flash(new JudgementResult(null, new OsuJudgement())); - } + Type = HitResult.Perfect + }); }, 3); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs index e212ceeba7..1700886263 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs @@ -1,54 +1,35 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Testing; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Osu; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneSkinnableScoreCounter : SkinnableTestScene + public class TestSceneSkinnableScoreCounter : SkinnableHUDComponentTestScene { - private IEnumerable scoreCounters => CreatedDrawables.OfType(); + [Cached] + private ScoreProcessor scoreProcessor = new ScoreProcessor(); - protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("Create combo counters", () => SetContents(() => - { - var comboCounter = new SkinnableScoreCounter(); - comboCounter.Current.Value = 1; - return comboCounter; - })); - } + protected override Drawable CreateDefaultImplementation() => new DefaultScoreCounter(); + protected override Drawable CreateLegacyImplementation() => new LegacyScoreCounter(); [Test] public void TestScoreCounterIncrementing() { - AddStep(@"Reset all", delegate - { - foreach (var s in scoreCounters) - s.Current.Value = 0; - }); + AddStep(@"Reset all", () => scoreProcessor.TotalScore.Value = 0); - AddStep(@"Hit! :D", delegate - { - foreach (var s in scoreCounters) - s.Current.Value += 300; - }); + AddStep(@"Hit! :D", () => scoreProcessor.TotalScore.Value += 300); } [Test] public void TestVeryLargeScore() { - AddStep("set large score", () => scoreCounters.ForEach(counter => counter.Current.Value = 1_000_000_000)); + AddStep("set large score", () => scoreProcessor.TotalScore.Value = 1_000_000_000); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs index d688e9cb21..d792405eeb 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -145,7 +145,7 @@ namespace osu.Game.Tests.Visual.Gameplay public Drawable GetDrawableComponent(ISkinComponent component) => source?.GetDrawableComponent(component); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => source?.GetTexture(componentName, wrapModeS, wrapModeT); - public Sample GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); + public ISample GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => source?.GetConfig(lookup); public void TriggerSourceChanged() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs index 841722a8f1..e08e03b789 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs @@ -33,7 +33,7 @@ namespace osu.Game.Tests.Visual.Gameplay var working = CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); working.LoadTrack(); - Child = gameplayClockContainer = new GameplayClockContainer(working, 0) + Child = gameplayClockContainer = new MasterGameplayClockContainer(working, 0) { RelativeSizeAxes = Axes.Both, Children = new Drawable[] @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("click", () => { - increment = skip_time - gameplayClock.CurrentTime - GameplayClockContainer.MINIMUM_SKIP_TIME / 2; + increment = skip_time - gameplayClock.CurrentTime - MasterGameplayClockContainer.MINIMUM_SKIP_TIME / 2; InputManager.Click(MouseButton.Left); }); AddStep("click", () => InputManager.Click(MouseButton.Left)); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 4a0e1282c4..e9894ff469 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -1,38 +1,42 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; -using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Online; +using osu.Game.Database; using osu.Game.Online.Spectator; -using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.UI; -using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Tests.Visual.Spectator; using osu.Game.Users; namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneSpectator : ScreenTestScene { - [Cached(typeof(SpectatorStreamingClient))] - private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient(); + private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" }; + + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient testSpectatorClient = new TestSpectatorClient(); + + [Cached(typeof(UserLookupCache))] + private UserLookupCache lookupCache = new TestUserLookupCache(); // used just to show beatmap card for the time being. protected override bool UseOnlineAPI => true; - private Spectator spectatorScreen; + private SoloSpectator spectatorScreen; [Resolved] private OsuGameBase game { get; set; } @@ -57,8 +61,8 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("add streaming client", () => { - Remove(testSpectatorStreamingClient); - Add(testSpectatorStreamingClient); + Remove(testSpectatorClient); + Add(testSpectatorClient); }); finish(); @@ -69,7 +73,7 @@ namespace osu.Game.Tests.Visual.Gameplay { loadSpectatingScreen(); - AddAssert("screen hasn't changed", () => Stack.CurrentScreen is Spectator); + AddAssert("screen hasn't changed", () => Stack.CurrentScreen is SoloSpectator); start(); sendFrames(); @@ -77,7 +81,7 @@ namespace osu.Game.Tests.Visual.Gameplay waitForPlayer(); AddAssert("ensure frames arrived", () => replayHandler.HasFrames); - AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null); + AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame); checkPaused(true); double? pausedTime = null; @@ -86,7 +90,7 @@ namespace osu.Game.Tests.Visual.Gameplay sendFrames(); - AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null); + AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame); checkPaused(true); AddAssert("time advanced", () => currentFrameStableTime > pausedTime); @@ -195,7 +199,7 @@ namespace osu.Game.Tests.Visual.Gameplay start(-1234); sendFrames(); - AddAssert("screen didn't change", () => Stack.CurrentScreen is Spectator); + AddAssert("screen didn't change", () => Stack.CurrentScreen is SoloSpectator); } private OsuFramedReplayInputHandler replayHandler => @@ -208,9 +212,9 @@ namespace osu.Game.Tests.Visual.Gameplay private void waitForPlayer() => AddUntilStep("wait for player", () => Stack.CurrentScreen is Player); - private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorStreamingClient.StartPlay(beatmapId ?? importedBeatmapId)); + private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId)); - private void finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(beatmapId ?? importedBeatmapId)); + private void finish() => AddStep("end play", () => testSpectatorClient.EndPlay(streamingUser.Id)); private void checkPaused(bool state) => AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state); @@ -219,87 +223,24 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("send frames", () => { - testSpectatorStreamingClient.SendFrames(nextFrame, count); + testSpectatorClient.SendFrames(streamingUser.Id, nextFrame, count); nextFrame += count; }); } private void loadSpectatingScreen() { - AddStep("load screen", () => LoadScreen(spectatorScreen = new Spectator(testSpectatorStreamingClient.StreamingUser))); + AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser))); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded); } - public class TestSpectatorStreamingClient : SpectatorStreamingClient + internal class TestUserLookupCache : UserLookupCache { - public readonly User StreamingUser = new User { Id = 55, Username = "Test user" }; - - public new BindableList PlayingUsers => (BindableList)base.PlayingUsers; - - private int beatmapId; - - public TestSpectatorStreamingClient() - : base(new DevelopmentEndpointConfiguration()) + protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User { - } - - public void StartPlay(int beatmapId) - { - this.beatmapId = beatmapId; - sendState(beatmapId); - } - - public void EndPlay(int beatmapId) - { - ((ISpectatorClient)this).UserFinishedPlaying(StreamingUser.Id, new SpectatorState - { - BeatmapID = beatmapId, - RulesetID = 0, - }); - - sentState = false; - } - - private bool sentState; - - public void SendFrames(int index, int count) - { - var frames = new List(); - - for (int i = index; i < index + count; i++) - { - var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1; - - frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); - } - - var bundle = new FrameDataBundle(new ScoreInfo(), frames); - ((ISpectatorClient)this).UserSentFrames(StreamingUser.Id, bundle); - - if (!sentState) - sendState(beatmapId); - } - - public override void WatchUser(int userId) - { - if (sentState) - { - // usually the server would do this. - sendState(beatmapId); - } - - base.WatchUser(userId); - } - - private void sendState(int beatmapId) - { - sentState = true; - ((ISpectatorClient)this).UserBeganPlaying(StreamingUser.Id, new SpectatorState - { - BeatmapID = beatmapId, - RulesetID = 0, - }); - } + Id = lookup, + Username = $"User {lookup}" + }); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index 35b3bfc1f8..469f594fdc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.Gameplay private IAPIProvider api { get; set; } [Resolved] - private SpectatorStreamingClient streamingClient { get; set; } + private SpectatorClient spectatorClient { get; set; } [Cached] private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap()); @@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay { replay = new Replay(); - users.BindTo(streamingClient.PlayingUsers); + users.BindTo(spectatorClient.PlayingUsers); users.BindCollectionChanged((obj, args) => { switch (args.Action) @@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Gameplay foreach (int user in args.NewItems) { if (user == api.LocalUser.Value.Id) - streamingClient.WatchUser(user); + spectatorClient.WatchUser(user); } break; @@ -91,14 +91,14 @@ namespace osu.Game.Tests.Visual.Gameplay foreach (int user in args.OldItems) { if (user == api.LocalUser.Value.Id) - streamingClient.StopWatchingUser(user); + spectatorClient.StopWatchingUser(user); } break; } }, true); - streamingClient.OnNewFrames += onNewFrames; + spectatorClient.OnNewFrames += onNewFrames; Add(new GridContainer { @@ -189,7 +189,7 @@ namespace osu.Game.Tests.Visual.Gameplay { } - private double latency = SpectatorStreamingClient.TIME_BETWEEN_SENDS; + private double latency = SpectatorClient.TIME_BETWEEN_SENDS; protected override void Update() { @@ -204,27 +204,27 @@ namespace osu.Game.Tests.Visual.Gameplay return; } - if (replayHandler.NextFrame != null) - { - var lastFrame = replay.Frames.LastOrDefault(); + if (!replayHandler.HasFrames) + return; - // this isn't perfect as we basically can't be aware of the rate-of-send here (the streamer is not sending data when not being moved). - // in gameplay playback, the case where NextFrame is null would pause gameplay and handle this correctly; it's strictly a test limitation / best effort implementation. - if (lastFrame != null) - latency = Math.Max(latency, Time.Current - lastFrame.Time); + var lastFrame = replay.Frames.LastOrDefault(); - latencyDisplay.Text = $"latency: {latency:N1}"; + // this isn't perfect as we basically can't be aware of the rate-of-send here (the streamer is not sending data when not being moved). + // in gameplay playback, the case where NextFrame is null would pause gameplay and handle this correctly; it's strictly a test limitation / best effort implementation. + if (lastFrame != null) + latency = Math.Max(latency, Time.Current - lastFrame.Time); - double proposedTime = Time.Current - latency + Time.Elapsed; + latencyDisplay.Text = $"latency: {latency:N1}"; - // this will either advance by one or zero frames. - double? time = replayHandler.SetFrameFromTime(proposedTime); + double proposedTime = Time.Current - latency + Time.Elapsed; - if (time == null) - return; + // this will either advance by one or zero frames. + double? time = replayHandler.SetFrameFromTime(proposedTime); - manualClock.CurrentTime = time.Value; - } + if (time == null) + return; + + manualClock.CurrentTime = time.Value; } [TearDownSteps] @@ -233,7 +233,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("stop recorder", () => { recorder.Expire(); - streamingClient.OnNewFrames -= onNewFrames; + spectatorClient.OnNewFrames -= onNewFrames; }); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs index 1544f8fd35..a718a98aa6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs @@ -22,7 +22,7 @@ namespace osu.Game.Tests.Visual.Gameplay [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - config.Set(OsuSetting.ShowStoryboard, true); + config.SetValue(OsuSetting.ShowStoryboard, true); storyboard = new Storyboard(); var backgroundLayer = storyboard.GetLayer("Background"); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs new file mode 100644 index 0000000000..5ef3eff856 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -0,0 +1,202 @@ +// 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 System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; +using osu.Game.Storyboards; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneStoryboardWithOutro : PlayerTestScene + { + protected override bool HasCustomSteps => true; + + protected new OutroPlayer Player => (OutroPlayer)base.Player; + + private double currentStoryboardDuration; + + private bool showResults = true; + + private event Func currentFailConditions; + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + AddStep("enable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, true)); + AddStep("set dim level to 0", () => LocalConfig.SetValue(OsuSetting.DimLevel, 0)); + AddStep("reset fail conditions", () => currentFailConditions = (_, __) => false); + AddStep("set storyboard duration to 2s", () => currentStoryboardDuration = 2000); + AddStep("set ShowResults = true", () => showResults = true); + } + + [Test] + public void TestStoryboardSkipOutro() + { + CreateTest(null); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddStep("skip outro", () => InputManager.Key(osuTK.Input.Key.Space)); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + AddUntilStep("time less than storyboard duration", () => Player.GameplayClockContainer.GameplayClock.CurrentTime < currentStoryboardDuration); + } + + [Test] + public void TestStoryboardNoSkipOutro() + { + CreateTest(null); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + } + + [Test] + public void TestStoryboardExitToSkipOutro() + { + CreateTest(null); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddStep("exit via pause", () => Player.ExitViaPause()); + AddAssert("score shown", () => Player.IsScoreShown); + } + + [TestCase(false)] + [TestCase(true)] + public void TestStoryboardToggle(bool enabledAtBeginning) + { + CreateTest(null); + AddStep($"{(enabledAtBeginning ? "enable" : "disable")} storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, enabledAtBeginning)); + AddStep("toggle storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, !enabledAtBeginning)); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + } + + [Test] + public void TestOutroEndsDuringFailAnimation() + { + CreateTest(() => + { + AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true); + AddStep("set storyboard duration to 1.3s", () => currentStoryboardDuration = 1300); + }); + AddUntilStep("wait for fail", () => Player.HasFailed); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible); + } + + [Test] + public void TestShowResultsFalse() + { + CreateTest(() => + { + AddStep("set ShowResults = false", () => showResults = false); + }); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddWaitStep("wait", 10); + AddAssert("no score shown", () => !Player.IsScoreShown); + } + + [Test] + public void TestStoryboardEndsBeforeCompletion() + { + CreateTest(() => AddStep("set storyboard duration to .1s", () => currentStoryboardDuration = 100)); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddUntilStep("wait for score shown", () => Player.IsScoreShown); + } + + [Test] + public void TestStoryboardRewind() + { + SkipOverlay.FadeContainer fadeContainer() => Player.ChildrenOfType().First(); + + CreateTest(null); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible); + + AddStep("rewind", () => Player.GameplayClockContainer.Seek(-1000)); + AddUntilStep("skip overlay content not visible", () => fadeContainer().State == Visibility.Hidden); + + AddUntilStep("skip overlay content becomes visible", () => fadeContainer().State == Visibility.Visible); + AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); + } + + [Test] + public void TestPerformExitNoOutro() + { + CreateTest(null); + AddStep("disable storyboard", () => LocalConfig.SetValue(OsuSetting.ShowStoryboard, false)); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + AddStep("exit via pause", () => Player.ExitViaPause()); + AddAssert("player exited", () => Stack.CurrentScreen == null); + } + + protected override bool AllowFail => true; + + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new OutroPlayer(currentFailConditions, showResults); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new Beatmap(); + beatmap.HitObjects.Add(new HitCircle()); + return beatmap; + } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + { + return base.CreateWorkingBeatmap(beatmap, createStoryboard(currentStoryboardDuration)); + } + + private Storyboard createStoryboard(double duration) + { + var storyboard = new Storyboard(); + var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero); + sprite.TimelineGroup.Alpha.Add(Easing.None, 0, duration, 1, 0); + storyboard.GetLayer("Background").Add(sprite); + return storyboard; + } + + protected class OutroPlayer : TestPlayer + { + public void ExitViaPause() => PerformExit(true); + + public new FailOverlay FailOverlay => base.FailOverlay; + + public bool IsScoreShown => !this.IsCurrentScreen() && this.GetChildScreen() is ResultsScreen; + + private event Func failConditions; + + public OutroPlayer(Func failConditions, bool showResults = true) + : base(false, showResults) + { + this.failConditions = failConditions; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + HealthProcessor.FailConditions += failConditions; + } + + protected override Task ImportScore(Score score) + { + return Task.CompletedTask; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index 960aad10c6..dfb78a235b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait(); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs new file mode 100644 index 0000000000..5ad35be0ec --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Database; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osu.Game.Screens.Play.HUD; +using osu.Game.Tests.Visual.Spectator; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene + { + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient spectatorClient = new TestSpectatorClient(); + + [Cached(typeof(UserLookupCache))] + private UserLookupCache lookupCache = new TestUserLookupCache(); + + protected override Container Content => content; + private readonly Container content; + + private readonly Dictionary clocks = new Dictionary + { + { PLAYER_1_ID, new ManualClock() }, + { PLAYER_2_ID, new ManualClock() } + }; + + public TestSceneMultiSpectatorLeaderboard() + { + base.Content.AddRange(new Drawable[] + { + spectatorClient, + lookupCache, + content = new Container { RelativeSizeAxes = Axes.Both } + }); + } + + [SetUpSteps] + public new void SetUpSteps() + { + MultiSpectatorLeaderboard leaderboard = null; + + AddStep("reset", () => + { + Clear(); + + foreach (var (userId, clock) in clocks) + { + spectatorClient.EndPlay(userId); + clock.CurrentTime = 0; + } + }); + + AddStep("create leaderboard", () => + { + foreach (var (userId, _) in clocks) + spectatorClient.StartPlay(userId, 0); + + Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); + + var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); + var scoreProcessor = new OsuScoreProcessor(); + scoreProcessor.ApplyBeatmap(playable); + + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, clocks.Keys.ToArray()) { Expanded = { Value = true } }, Add); + }); + + AddUntilStep("wait for load", () => leaderboard.IsLoaded); + + AddStep("add clock sources", () => + { + foreach (var (userId, clock) in clocks) + leaderboard.AddClock(userId, clock); + }); + } + + [Test] + public void TestLeaderboardTracksCurrentTime() + { + AddStep("send frames", () => + { + // For player 1, send frames in sets of 1. + // For player 2, send frames in sets of 10. + for (int i = 0; i < 100; i++) + { + spectatorClient.SendFrames(PLAYER_1_ID, i, 1); + + if (i % 10 == 0) + spectatorClient.SendFrames(PLAYER_2_ID, i, 10); + } + }); + + assertCombo(PLAYER_1_ID, 1); + assertCombo(PLAYER_2_ID, 10); + + // Advance to a point where only user player 1's frame changes. + setTime(500); + assertCombo(PLAYER_1_ID, 5); + assertCombo(PLAYER_2_ID, 10); + + // Advance to a point where both user's frame changes. + setTime(1100); + assertCombo(PLAYER_1_ID, 11); + assertCombo(PLAYER_2_ID, 20); + + // Advance user player 2 only to a point where its frame changes. + setTime(PLAYER_2_ID, 2100); + assertCombo(PLAYER_1_ID, 11); + assertCombo(PLAYER_2_ID, 30); + + // Advance both users beyond their last frame + setTime(101 * 100); + assertCombo(PLAYER_1_ID, 100); + assertCombo(PLAYER_2_ID, 100); + } + + [Test] + public void TestNoFrames() + { + assertCombo(PLAYER_1_ID, 0); + assertCombo(PLAYER_2_ID, 0); + } + + private void setTime(double time) => AddStep($"set time {time}", () => + { + foreach (var (_, clock) in clocks) + clock.CurrentTime = time; + }); + + private void setTime(int userId, double time) + => AddStep($"set user {userId} time {time}", () => clocks[userId].CurrentTime = time); + + private void assertCombo(int userId, int expectedCombo) + => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo); + + private class TestUserLookupCache : UserLookupCache + { + protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) + { + return Task.FromResult(new User + { + Id = lookup, + Username = $"User {lookup}" + }); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs new file mode 100644 index 0000000000..b91391c409 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -0,0 +1,313 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.Spectator; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osu.Game.Screens.Play; +using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Visual.Spectator; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiSpectatorScreen : MultiplayerTestScene + { + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient spectatorClient = new TestSpectatorClient(); + + [Cached(typeof(UserLookupCache))] + private UserLookupCache lookupCache = new TestUserLookupCache(); + + [Resolved] + private OsuGameBase game { get; set; } + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + private MultiSpectatorScreen spectatorScreen; + + private readonly List playingUserIds = new List(); + private readonly Dictionary nextFrame = new Dictionary(); + + private BeatmapSetInfo importedSet; + private BeatmapInfo importedBeatmap; + private int importedBeatmapId; + + [BackgroundDependencyLoader] + private void load() + { + importedSet = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result; + importedBeatmap = importedSet.Beatmaps.First(b => b.RulesetID == 0); + importedBeatmapId = importedBeatmap.OnlineBeatmapID ?? -1; + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reset sent frames", () => nextFrame.Clear()); + + AddStep("add streaming client", () => + { + Remove(spectatorClient); + Add(spectatorClient); + }); + + AddStep("finish previous gameplay", () => + { + foreach (var id in playingUserIds) + spectatorClient.EndPlay(id); + playingUserIds.Clear(); + }); + } + + [Test] + public void TestDelayedStart() + { + AddStep("start players silently", () => + { + Client.CurrentMatchPlayingUserIds.Add(PLAYER_1_ID); + Client.CurrentMatchPlayingUserIds.Add(PLAYER_2_ID); + playingUserIds.Add(PLAYER_1_ID); + playingUserIds.Add(PLAYER_2_ID); + nextFrame[PLAYER_1_ID] = 0; + nextFrame[PLAYER_2_ID] = 0; + }); + + loadSpectateScreen(false); + + AddWaitStep("wait a bit", 10); + AddStep("load player first_player_id", () => spectatorClient.StartPlay(PLAYER_1_ID, importedBeatmapId)); + AddUntilStep("one player added", () => spectatorScreen.ChildrenOfType().Count() == 1); + + AddWaitStep("wait a bit", 10); + AddStep("load player second_player_id", () => spectatorClient.StartPlay(PLAYER_2_ID, importedBeatmapId)); + AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType().Count() == 2); + } + + [Test] + public void TestGeneral() + { + int[] userIds = Enumerable.Range(0, 4).Select(i => PLAYER_1_ID + i).ToArray(); + + start(userIds); + loadSpectateScreen(); + + sendFrames(userIds, 1000); + AddWaitStep("wait a bit", 20); + } + + [Test] + public void TestPlayersMustStartSimultaneously() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + // Send frames for one player only, both should remain paused. + sendFrames(PLAYER_1_ID, 20); + checkPausedInstant(PLAYER_1_ID, true); + checkPausedInstant(PLAYER_2_ID, true); + + // Send frames for the other player, both should now start playing. + sendFrames(PLAYER_2_ID, 20); + checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID, false); + } + + [Test] + public void TestPlayersDoNotStartSimultaneouslyIfBufferingForMaximumStartDelay() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + // Send frames for one player only, both should remain paused. + sendFrames(PLAYER_1_ID, 1000); + checkPausedInstant(PLAYER_1_ID, true); + checkPausedInstant(PLAYER_2_ID, true); + + // Wait for the start delay seconds... + AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + + // Player 1 should start playing by itself, player 2 should remain paused. + checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID, true); + } + + [Test] + public void TestPlayersContinueWhileOthersBuffer() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + // Send initial frames for both players. A few more for player 1. + sendFrames(PLAYER_1_ID, 20); + sendFrames(PLAYER_2_ID, 10); + checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID, false); + + // Eventually player 2 will pause, player 1 must remain running. + checkPaused(PLAYER_2_ID, true); + checkPausedInstant(PLAYER_1_ID, false); + + // Eventually both players will run out of frames and should pause. + checkPaused(PLAYER_1_ID, true); + checkPausedInstant(PLAYER_2_ID, true); + + // Send more frames for the first player only. Player 1 should start playing with player 2 remaining paused. + sendFrames(PLAYER_1_ID, 20); + checkPausedInstant(PLAYER_2_ID, true); + checkPausedInstant(PLAYER_1_ID, false); + + // Send more frames for the second player. Both should be playing + sendFrames(PLAYER_2_ID, 20); + checkPausedInstant(PLAYER_2_ID, false); + checkPausedInstant(PLAYER_1_ID, false); + } + + [Test] + public void TestPlayersCatchUpAfterFallingBehind() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + // Send initial frames for both players. A few more for player 1. + sendFrames(PLAYER_1_ID, 1000); + sendFrames(PLAYER_2_ID, 10); + checkPausedInstant(PLAYER_1_ID, false); + checkPausedInstant(PLAYER_2_ID, false); + + // Eventually player 2 will run out of frames and should pause. + checkPaused(PLAYER_2_ID, true); + AddWaitStep("wait a few more frames", 10); + + // Send more frames for player 2. It should unpause. + sendFrames(PLAYER_2_ID, 1000); + checkPausedInstant(PLAYER_2_ID, false); + + // Player 2 should catch up to player 1 after unpausing. + waitForCatchup(PLAYER_2_ID); + AddWaitStep("wait a bit", 10); + } + + [Test] + public void TestMostInSyncUserIsAudioSource() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + assertMuted(PLAYER_1_ID, true); + assertMuted(PLAYER_2_ID, true); + + sendFrames(PLAYER_1_ID, 10); + sendFrames(PLAYER_2_ID, 20); + assertMuted(PLAYER_1_ID, false); + assertMuted(PLAYER_2_ID, true); + + checkPaused(PLAYER_1_ID, true); + assertMuted(PLAYER_1_ID, true); + assertMuted(PLAYER_2_ID, false); + + sendFrames(PLAYER_1_ID, 100); + waitForCatchup(PLAYER_1_ID); + checkPaused(PLAYER_2_ID, true); + assertMuted(PLAYER_1_ID, false); + assertMuted(PLAYER_2_ID, true); + + sendFrames(PLAYER_2_ID, 100); + waitForCatchup(PLAYER_2_ID); + assertMuted(PLAYER_1_ID, false); + assertMuted(PLAYER_2_ID, true); + } + + private void loadSpectateScreen(bool waitForPlayerLoad = true) + { + AddStep("load screen", () => + { + Beatmap.Value = beatmapManager.GetWorkingBeatmap(importedBeatmap); + Ruleset.Value = importedBeatmap.Ruleset; + + LoadScreen(spectatorScreen = new MultiSpectatorScreen(playingUserIds.ToArray())); + }); + + AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded && (!waitForPlayerLoad || spectatorScreen.AllPlayersLoaded)); + } + + private void start(int userId, int? beatmapId = null) => start(new[] { userId }, beatmapId); + + private void start(int[] userIds, int? beatmapId = null) + { + AddStep("start play", () => + { + foreach (int id in userIds) + { + Client.CurrentMatchPlayingUserIds.Add(id); + spectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId); + playingUserIds.Add(id); + nextFrame[id] = 0; + } + }); + } + + private void finish(int userId) + { + AddStep("end play", () => + { + spectatorClient.EndPlay(userId); + playingUserIds.Remove(userId); + nextFrame.Remove(userId); + }); + } + + private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count); + + private void sendFrames(int[] userIds, int count = 10) + { + AddStep("send frames", () => + { + foreach (int id in userIds) + { + spectatorClient.SendFrames(id, nextFrame[id], count); + nextFrame[id] += count; + } + }); + } + + private void checkPaused(int userId, bool state) + => AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); + + private void checkPausedInstant(int userId, bool state) + => AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); + + private void assertMuted(int userId, bool muted) + => AddAssert($"{userId} {(muted ? "is" : "is not")} muted", () => getInstance(userId).Mute == muted); + + private void waitForCatchup(int userId) + => AddUntilStep($"{userId} not catching up", () => !getInstance(userId).GameplayClock.IsCatchingUp); + + private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType().Single(); + + private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType().Single(p => p.UserId == userId); + + internal class TestUserLookupCache : UserLookupCache + { + protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) + { + return Task.FromResult(new User + { + Id = lookup, + Username = $"User {lookup}" + }); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 2e39471dc0..c5a6723508 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -1,46 +1,208 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Tests.Resources; using osu.Game.Users; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayer : MultiplayerTestScene + public class TestSceneMultiplayer : ScreenTestScene { + private TestMultiplayer multiplayerScreen; + + private BeatmapManager beatmaps; + private RulesetStore rulesets; + private BeatmapSetInfo importedSet; + + private TestMultiplayerClient client => multiplayerScreen.Client; + private Room room => client.APIRoom; + public TestSceneMultiplayer() { - var multi = new TestMultiplayer(); + loadMultiplayer(); + } - AddStep("show", () => LoadScreen(multi)); - AddUntilStep("wait for loaded", () => multi.IsLoaded); + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); + } + + [SetUp] + public void Setup() => Schedule(() => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + }); + + [Test] + public void TestUserSetToIdleWhenBeatmapDeleted() + { + loadMultiplayer(); + + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddStep("set user ready", () => client.ChangeState(MultiplayerUserState.Ready)); + AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); + + AddUntilStep("user state is idle", () => client.LocalUser?.State == MultiplayerUserState.Idle); } [Test] - public void TestOneUserJoinedMultipleTimes() + public void TestLocalPlayDoesNotStartWhileSpectatingWithNoBeatmap() { - var user = new User { Id = 33 }; + loadMultiplayer(); - AddRepeatStep("add user multiple times", () => Client.AddUser(user), 3); + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); - AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + AddStep("join other user (ready, host)", () => + { + client.AddUser(new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Other" }); + client.TransferHost(MultiplayerTestScene.PLAYER_1_ID); + client.ChangeUserState(MultiplayerTestScene.PLAYER_1_ID, MultiplayerUserState.Ready); + }); + + AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); + + AddStep("click spectate button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("start match externally", () => client.StartMatch()); + + AddAssert("play not started", () => multiplayerScreen.IsCurrentScreen()); } [Test] - public void TestOneUserLeftMultipleTimes() + public void TestLocalPlayStartsWhileSpectatingWhenBeatmapBecomesAvailable() { - var user = new User { Id = 44 }; + loadMultiplayer(); - AddStep("add user", () => Client.AddUser(user)); - AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); - AddRepeatStep("remove user multiple times", () => Client.RemoveUser(user), 3); - AddAssert("room has 1 user", () => Client.Room?.Users.Count == 1); + AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); + + AddStep("join other user (ready, host)", () => + { + client.AddUser(new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Other" }); + client.TransferHost(MultiplayerTestScene.PLAYER_1_ID); + client.ChangeUserState(MultiplayerTestScene.PLAYER_1_ID, MultiplayerUserState.Ready); + }); + + AddStep("click spectate button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddStep("start match externally", () => client.StartMatch()); + + AddStep("restore beatmap", () => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + }); + + AddUntilStep("play started", () => !multiplayerScreen.IsCurrentScreen()); + } + + private void createRoom(Func room) + { + AddStep("open room", () => + { + multiplayerScreen.OpenNewRoom(room()); + }); + + AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + AddWaitStep("wait for transition", 2); + + AddStep("create room", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for join", () => client.Room != null); + } + + private void loadMultiplayer() + { + AddStep("show", () => + { + multiplayerScreen = new TestMultiplayer(); + + // Needs to be added at a higher level since the multiplayer screen becomes non-current. + Child = multiplayerScreen.Client; + + LoadScreen(multiplayerScreen); + }); + + AddUntilStep("wait for loaded", () => multiplayerScreen.IsLoaded); } private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer { + [Cached(typeof(MultiplayerClient))] + public readonly TestMultiplayerClient Client; + + public TestMultiplayer() + { + Client = new TestMultiplayerClient((TestMultiplayerRoomManager)RoomManager); + } + protected override RoomManager CreateRoomManager() => new TestMultiplayerRoomManager(); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index 1ee848b902..af2f6fa5fe 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -6,13 +6,12 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Database; -using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; @@ -21,6 +20,7 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Tests.Visual.Online; +using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Multiplayer { @@ -28,8 +28,8 @@ namespace osu.Game.Tests.Visual.Multiplayer { private const int users = 16; - [Cached(typeof(SpectatorStreamingClient))] - private TestMultiplayerStreaming streamingClient = new TestMultiplayerStreaming(users); + [Cached(typeof(SpectatorClient))] + private TestMultiplayerSpectatorClient spectatorClient = new TestMultiplayerSpectatorClient(); [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache(); @@ -38,16 +38,24 @@ namespace osu.Game.Tests.Visual.Multiplayer protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + private OsuConfigManager config; + public TestSceneMultiplayerGameplayLeaderboard() { base.Content.Children = new Drawable[] { - streamingClient, + spectatorClient, lookupCache, Content }; } + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); + } + [SetUpSteps] public override void SetUpSteps() { @@ -62,10 +70,14 @@ namespace osu.Game.Tests.Visual.Multiplayer var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); - streamingClient.Start(Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); + for (int i = 0; i < users; i++) + spectatorClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); - Client.CurrentMatchPlayingUserIds.Clear(); - Client.CurrentMatchPlayingUserIds.AddRange(streamingClient.PlayingUsers); + spectatorClient.Schedule(() => + { + Client.CurrentMatchPlayingUserIds.Clear(); + Client.CurrentMatchPlayingUserIds.AddRange(spectatorClient.PlayingUsers); + }); Children = new Drawable[] { @@ -74,7 +86,7 @@ namespace osu.Game.Tests.Visual.Multiplayer scoreProcessor.ApplyBeatmap(playable); - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, streamingClient.PlayingUsers.ToArray()) + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, spectatorClient.PlayingUsers.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -82,12 +94,13 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddUntilStep("wait for load", () => leaderboard.IsLoaded); + AddUntilStep("wait for user population", () => Client.CurrentMatchPlayingUserIds.Count > 0); } [Test] public void TestScoreUpdates() { - AddRepeatStep("update state", () => streamingClient.RandomlyUpdateState(), 100); + AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 100); AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded); } @@ -97,30 +110,16 @@ namespace osu.Game.Tests.Visual.Multiplayer AddRepeatStep("mark user quit", () => Client.CurrentMatchPlayingUserIds.RemoveAt(0), users); } - public class TestMultiplayerStreaming : SpectatorStreamingClient + [Test] + public void TestChangeScoringMode() { - public new BindableList PlayingUsers => (BindableList)base.PlayingUsers; - - private readonly int totalUsers; - - public TestMultiplayerStreaming(int totalUsers) - : base(new DevelopmentEndpointConfiguration()) - { - this.totalUsers = totalUsers; - } - - public void Start(int beatmapId) - { - for (int i = 0; i < totalUsers; i++) - { - ((ISpectatorClient)this).UserBeganPlaying(i, new SpectatorState - { - BeatmapID = beatmapId, - RulesetID = 0, - }); - } - } + AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 5); + AddStep("change to classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic)); + AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); + } + public class TestMultiplayerSpectatorClient : TestSpectatorClient + { private readonly Dictionary lastHeaders = new Dictionary(); public void RandomlyUpdateState() @@ -163,7 +162,7 @@ namespace osu.Game.Tests.Visual.Multiplayer break; } - ((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, Array.Empty())); + ((ISpectatorClient)this).UserSentFrames(userId, new FrameDataBundle(header, new[] { new LegacyReplayFrame(Time.Current, 0, 0, ReplayButtonState.None) })); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs new file mode 100644 index 0000000000..6b03b53b4b --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerMatchFooter : MultiplayerTestScene + { + [Cached] + private readonly OnlinePlayBeatmapAvailabilityTracker availablilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); + + [BackgroundDependencyLoader] + private void load() + { + Child = new MultiplayerMatchFooter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Height = 50 + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 95c333e9f4..5b059c06f5 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -7,17 +7,23 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Select; @@ -36,7 +42,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); beatmaps = new List(); @@ -137,8 +143,30 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("mods not changed", () => SelectedMods.Value.Single() is TaikoModDoubleTime); } + [TestCase(typeof(OsuModHidden), typeof(OsuModHidden))] // Same mod. + [TestCase(typeof(OsuModHidden), typeof(OsuModTraceable))] // Incompatible. + public void TestAllowedModDeselectedWhenRequired(Type allowedMod, Type requiredMod) + { + AddStep($"select {allowedMod.ReadableName()} as allowed", () => songSelect.FreeMods.Value = new[] { (Mod)Activator.CreateInstance(allowedMod) }); + AddStep($"select {requiredMod.ReadableName()} as required", () => songSelect.Mods.Value = new[] { (Mod)Activator.CreateInstance(requiredMod) }); + + AddAssert("freemods empty", () => songSelect.FreeMods.Value.Count == 0); + assertHasFreeModButton(allowedMod, false); + assertHasFreeModButton(requiredMod, false); + } + + private void assertHasFreeModButton(Type type, bool hasButton = true) + { + AddAssert($"{type.ReadableName()} {(hasButton ? "displayed" : "not displayed")} in freemod overlay", + () => songSelect.ChildrenOfType().Single().ChildrenOfType().All(b => b.Mod.GetType() != type)); + } + private class TestMultiplayerMatchSongSelect : MultiplayerMatchSongSelect { + public new Bindable> Mods => base.Mods; + + public new Bindable> FreeMods => base.FreeMods; + public new BeatmapCarousel Carousel => base.Carousel; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 2344ebea0e..e8ebc0c426 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -4,14 +4,21 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; -using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osu.Game.Users; using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer @@ -20,14 +27,25 @@ namespace osu.Game.Tests.Visual.Multiplayer { private MultiplayerMatchSubScreen screen; - [Cached] - private OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker(); + private BeatmapManager beatmaps; + private RulesetStore rulesets; + private BeatmapSetInfo importedSet; public TestSceneMultiplayerMatchSubScreen() : base(false) { } + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + } + [SetUp] public new void Setup() => Schedule(() => { @@ -76,7 +94,50 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); - AddWaitStep("wait", 10); + AddUntilStep("wait for join", () => Client.Room != null); + } + + [Test] + public void TestStartMatchWhileSpectating() + { + AddStep("set playlist", () => + { + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + }); + }); + + AddStep("click create button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for room join", () => Client.Room != null); + + AddStep("join other user (ready)", () => + { + Client.AddUser(new User { Id = PLAYER_1_ID }); + Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready); + }); + + AddStep("click spectate button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for ready button to be enabled", () => this.ChildrenOfType().Single().ChildrenOfType().Single().Enabled.Value); + + AddStep("click ready button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("match started", () => Client.Room?.State == MultiplayerRoomState.WaitingForLoad); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index e713cff233..7f8f04b718 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -126,6 +126,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("ready mark invisible", () => !this.ChildrenOfType().Single().IsPresent); } + [Test] + public void TestToggleSpectateState() + { + AddStep("make user spectating", () => Client.ChangeState(MultiplayerUserState.Spectating)); + AddStep("make user idle", () => Client.ChangeState(MultiplayerUserState.Idle)); + } + [Test] public void TestCrownChangesStateWhenHostTransferred() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs index 3b3b1bee86..929cd6ca80 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public class TestSceneMultiplayerReadyButton : MultiplayerTestScene { private MultiplayerReadyButton button; - private OnlinePlayBeatmapAvailablilityTracker beatmapTracker; + private OnlinePlayBeatmapAvailabilityTracker beatmapTracker; private BeatmapSetInfo importedSet; private readonly Bindable selectedItem = new Bindable(); @@ -41,10 +41,10 @@ namespace osu.Game.Tests.Visual.Multiplayer private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); - beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait(); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); - Add(beatmapTracker = new OnlinePlayBeatmapAvailablilityTracker + Add(beatmapTracker = new OnlinePlayBeatmapAvailabilityTracker { SelectedItem = { BindTarget = selectedItem } }); @@ -209,9 +209,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { addClickButtonStep(); AddAssert("user waiting for load", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad); - AddAssert("ready button disabled", () => !button.ChildrenOfType().Single().Enabled.Value); + AddAssert("ready button disabled", () => !button.ChildrenOfType().Single().Enabled.Value); AddStep("transitioned to gameplay", () => readyClickOperation.Dispose()); + + AddStep("finish gameplay", () => + { + Client.ChangeUserState(Client.Room?.Users[0].UserID ?? 0, MultiplayerUserState.Loaded); + Client.ChangeUserState(Client.Room?.Users[0].UserID ?? 0, MultiplayerUserState.FinishedPlay); + }); + AddAssert("ready button enabled", () => button.ChildrenOfType().Single().Enabled.Value); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs index 6de5704410..c008771fd9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs @@ -1,10 +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 NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Multiplayer { @@ -21,15 +24,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room { Name = { Value = "1" } }); + roomManager.CreateRoom(createRoom(r => r.Name.Value = "1")); roomManager.PartRoom(); - roomManager.CreateRoom(new Room { Name = { Value = "2" } }); + roomManager.CreateRoom(createRoom(r => r.Name.Value = "2")); roomManager.PartRoom(); roomManager.ClearRooms(); }); }); - AddAssert("manager polled for rooms", () => roomManager.Rooms.Count == 2); + AddAssert("manager polled for rooms", () => ((RoomManager)roomManager).Rooms.Count == 2); AddAssert("initial rooms received", () => roomManager.InitialRoomsReceived.Value); } @@ -40,16 +43,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); }); }); AddStep("disconnect", () => roomContainer.Client.Disconnect()); - AddAssert("rooms cleared", () => roomManager.Rooms.Count == 0); + AddAssert("rooms cleared", () => ((RoomManager)roomManager).Rooms.Count == 0); AddAssert("initial rooms not received", () => !roomManager.InitialRoomsReceived.Value); } @@ -60,9 +63,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); }); }); @@ -70,7 +73,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("disconnect", () => roomContainer.Client.Disconnect()); AddStep("connect", () => roomContainer.Client.Connect()); - AddAssert("manager polled for rooms", () => roomManager.Rooms.Count == 2); + AddAssert("manager polled for rooms", () => ((RoomManager)roomManager).Rooms.Count == 2); AddAssert("initial rooms received", () => roomManager.InitialRoomsReceived.Value); } @@ -81,12 +84,12 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.ClearRooms(); }); }); - AddAssert("manager not polled for rooms", () => roomManager.Rooms.Count == 0); + AddAssert("manager not polled for rooms", () => ((RoomManager)roomManager).Rooms.Count == 0); AddAssert("initial rooms not received", () => !roomManager.InitialRoomsReceived.Value); } @@ -97,7 +100,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); }); }); @@ -111,7 +114,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - roomManager.CreateRoom(new Room()); + roomManager.CreateRoom(createRoom()); roomManager.PartRoom(); }); }); @@ -126,7 +129,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { createRoomManager().With(d => d.OnLoadComplete += _ => { - var r = new Room(); + var r = createRoom(); roomManager.CreateRoom(r); roomManager.PartRoom(); roomManager.JoinRoom(r); @@ -136,6 +139,28 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("multiplayer room joined", () => roomContainer.Client.Room != null); } + private Room createRoom(Action initFunc = null) + { + var room = new Room + { + Name = + { + Value = "test room" + }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, + Ruleset = { Value = Ruleset.Value } + } + } + }; + + initFunc?.Invoke(room); + return room; + } + private TestMultiplayerRoomManager createRoomManager() { Child = roomContainer = new TestMultiplayerRoomContainer diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs new file mode 100644 index 0000000000..d00404102c --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs @@ -0,0 +1,194 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Tests.Resources; +using osu.Game.Users; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerSpectateButton : MultiplayerTestScene + { + private MultiplayerSpectateButton spectateButton; + private MultiplayerReadyButton readyButton; + + private readonly Bindable selectedItem = new Bindable(); + + private BeatmapSetInfo importedSet; + private BeatmapManager beatmaps; + private RulesetStore rulesets; + + private IDisposable readyClickOperation; + + protected override Container Content => content; + private readonly Container content; + + public TestSceneMultiplayerSpectateButton() + { + base.Content.Add(content = new Container + { + RelativeSizeAxes = Axes.Both + }); + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + return dependencies; + } + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); + + var beatmapTracker = new OnlinePlayBeatmapAvailabilityTracker { SelectedItem = { BindTarget = selectedItem } }; + base.Content.Add(beatmapTracker); + Dependencies.Cache(beatmapTracker); + + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + } + + [SetUp] + public new void Setup() => Schedule(() => + { + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); + selectedItem.Value = new PlaylistItem + { + Beatmap = { Value = Beatmap.Value.BeatmapInfo }, + Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset }, + }; + + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + spectateButton = new MultiplayerSpectateButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), + OnSpectateClick = async () => + { + readyClickOperation = OngoingOperationTracker.BeginOperation(); + await Client.ToggleSpectate(); + readyClickOperation.Dispose(); + } + }, + readyButton = new MultiplayerReadyButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(200, 50), + OnReadyClick = async () => + { + readyClickOperation = OngoingOperationTracker.BeginOperation(); + + if (Client.IsHost && Client.LocalUser?.State == MultiplayerUserState.Ready) + { + await Client.StartMatch(); + return; + } + + await Client.ToggleReady(); + readyClickOperation.Dispose(); + } + } + } + }; + }); + + [TestCase(MultiplayerRoomState.Open)] + [TestCase(MultiplayerRoomState.WaitingForLoad)] + [TestCase(MultiplayerRoomState.Playing)] + public void TestEnabledWhenRoomOpenOrInGameplay(MultiplayerRoomState roomState) + { + AddStep($"change room to {roomState}", () => Client.ChangeRoomState(roomState)); + assertSpectateButtonEnablement(true); + } + + [TestCase(MultiplayerUserState.Idle)] + [TestCase(MultiplayerUserState.Ready)] + public void TestToggleWhenIdle(MultiplayerUserState initialState) + { + addClickSpectateButtonStep(); + AddAssert("user is spectating", () => Client.Room?.Users[0].State == MultiplayerUserState.Spectating); + + addClickSpectateButtonStep(); + AddAssert("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); + } + + [TestCase(MultiplayerRoomState.Closed)] + public void TestDisabledWhenClosed(MultiplayerRoomState roomState) + { + AddStep($"change room to {roomState}", () => Client.ChangeRoomState(roomState)); + assertSpectateButtonEnablement(false); + } + + [Test] + public void TestReadyButtonDisabledWhenHostAndNoReadyUsers() + { + addClickSpectateButtonStep(); + assertReadyButtonEnablement(false); + } + + [Test] + public void TestReadyButtonEnabledWhenHostAndUsersReady() + { + AddStep("add user", () => Client.AddUser(new User { Id = PLAYER_1_ID })); + AddStep("set user ready", () => Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready)); + + addClickSpectateButtonStep(); + assertReadyButtonEnablement(true); + } + + [Test] + public void TestReadyButtonDisabledWhenNotHostAndUsersReady() + { + AddStep("add user and transfer host", () => + { + Client.AddUser(new User { Id = PLAYER_1_ID }); + Client.TransferHost(PLAYER_1_ID); + }); + + AddStep("set user ready", () => Client.ChangeUserState(PLAYER_1_ID, MultiplayerUserState.Ready)); + + addClickSpectateButtonStep(); + assertReadyButtonEnablement(false); + } + + private void addClickSpectateButtonStep() => AddStep("click spectate button", () => + { + InputManager.MoveMouseTo(spectateButton); + InputManager.Click(MouseButton.Left); + }); + + private void assertSpectateButtonEnablement(bool shouldBeEnabled) + => AddAssert($"spectate button {(shouldBeEnabled ? "is" : "is not")} enabled", () => spectateButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); + + private void assertReadyButtonEnablement(bool shouldBeEnabled) + => AddAssert($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs new file mode 100644 index 0000000000..c0958c7fe8 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectatorPlayerGrid.cs @@ -0,0 +1,115 @@ +// 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.Shapes; +using osu.Framework.Utils; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerSpectatorPlayerGrid : OsuManualInputManagerTestScene + { + private PlayerGrid grid; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = grid = new PlayerGrid { RelativeSizeAxes = Axes.Both }; + }); + + [Test] + public void TestMaximiseAndMinimise() + { + addCells(2); + + assertMaximisation(0, false, true); + assertMaximisation(1, false, true); + + clickCell(0); + assertMaximisation(0, true); + assertMaximisation(1, false, true); + clickCell(0); + assertMaximisation(0, false); + assertMaximisation(1, false, true); + + clickCell(1); + assertMaximisation(1, true); + assertMaximisation(0, false, true); + clickCell(1); + assertMaximisation(1, false); + assertMaximisation(0, false, true); + } + + [Test] + public void TestClickBothCellsSimultaneously() + { + addCells(2); + + AddStep("click cell 0 then 1", () => + { + InputManager.MoveMouseTo(grid.Content.ElementAt(0)); + InputManager.Click(MouseButton.Left); + + InputManager.MoveMouseTo(grid.Content.ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + + assertMaximisation(1, true); + assertMaximisation(0, false); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(9)] + [TestCase(11)] + [TestCase(12)] + [TestCase(15)] + [TestCase(16)] + public void TestCellCount(int count) + { + addCells(count); + AddWaitStep("wait for display", 2); + } + + private void addCells(int count) => AddStep($"add {count} grid cells", () => + { + for (int i = 0; i < count; i++) + grid.Add(new GridContent()); + }); + + private void clickCell(int index) => AddStep($"click cell index {index}", () => + { + InputManager.MoveMouseTo(grid.Content.ElementAt(index)); + InputManager.Click(MouseButton.Left); + }); + + private void assertMaximisation(int index, bool shouldBeMaximised, bool instant = false) + { + string assertionText = $"cell index {index} {(shouldBeMaximised ? "is" : "is not")} maximised"; + + if (instant) + AddAssert(assertionText, checkAction); + else + AddUntilStep(assertionText, checkAction); + + bool checkAction() => Precision.AlmostEquals(grid.MaximisedFacade.DrawSize, grid.Content.ElementAt(index).DrawSize, 10) == shouldBeMaximised; + } + + private class GridContent : Box + { + public GridContent() + { + RelativeSizeAxes = Axes.Both; + Colour = new Color4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1f); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index 7d83ba569d..d95a95ebe5 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); var beatmaps = new List(); diff --git a/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs b/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs index 96393cc4c3..f9a991f756 100644 --- a/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs +++ b/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Platform; @@ -37,6 +36,8 @@ namespace osu.Game.Tests.Visual.Navigation protected override bool UseFreshStoragePerRun => true; + protected override bool CreateNestedActionContainer => false; + [BackgroundDependencyLoader] private void load(GameHost host) { @@ -62,10 +63,6 @@ namespace osu.Game.Tests.Visual.Navigation RecycleLocalStorage(); - // see MouseSettings - var frameworkConfig = host.Dependencies.Get(); - frameworkConfig.GetBindable(FrameworkSetting.CursorSensitivity).Disabled = false; - CreateGame(); }); @@ -82,7 +79,7 @@ namespace osu.Game.Tests.Visual.Navigation // todo: this can be removed once we can run audio tracks without a device present // see https://github.com/ppy/osu/issues/1302 - Game.LocalConfig.Set(OsuSetting.IntroSequence, IntroSequence.Circles); + Game.LocalConfig.SetValue(OsuSetting.IntroSequence, IntroSequence.Circles); Add(Game); } @@ -136,7 +133,7 @@ namespace osu.Game.Tests.Visual.Navigation base.LoadComplete(); API.Login("Rhythm Champion", "osu!"); - Dependencies.Get().Set(Static.MutedAudioNotificationShownOnce, true); + Dependencies.Get().SetValue(Static.MutedAudioNotificationShownOnce, true); } } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs index 21d3bdaae3..3cedaf9d45 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs @@ -11,8 +11,8 @@ using osu.Game.Overlays; using osu.Game.Screens; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; -using osu.Game.Screens.Select; using osuTK.Input; +using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation; namespace osu.Game.Tests.Visual.Navigation { @@ -37,17 +37,17 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtSongSelect() { - PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); - AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) })); + AddStep("perform immediately", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) })); AddAssert("did perform", () => actionPerformed); - AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("screen didn't change", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect); } [Test] public void TestPerformAtMenuFromSongSelect() { - PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); AddUntilStep("returned to menu", () => Game.ScreenStack.CurrentScreen is MainMenu); @@ -57,19 +57,19 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtSongSelectFromPlayerLoader() { - PushAndConfirm(() => new PlaySongSelect()); - PushAndConfirm(() => new PlayerLoader(() => new Player())); + PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer())); - AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) })); - AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) })); + AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect); AddAssert("did perform", () => actionPerformed); } [Test] public void TestPerformAtMenuFromPlayerLoader() { - PushAndConfirm(() => new PlaySongSelect()); - PushAndConfirm(() => new PlayerLoader(() => new Player())); + PushAndConfirm(() => new TestPlaySongSelect()); + PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer())); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is MainMenu); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index d8380b2dd3..253e448bb4 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Toolbar; @@ -33,9 +34,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectWithEscape() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); pushEscape(); @@ -43,6 +44,43 @@ namespace osu.Game.Tests.Visual.Navigation exitViaEscapeAndConfirm(); } + /// + /// This tests that the F1 key will open the mod select overlay, and not be handled / blocked by the music controller (which has the same default binding + /// but should be handled *after* song select). + /// + [Test] + public void TestOpenModSelectOverlayUsingAction() + { + TestPlaySongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddStep("Show mods overlay", () => InputManager.Key(Key.F1)); + AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + } + + [Test] + public void TestRetryCountIncrements() + { + Player player = null; + + PushAndConfirm(() => new TestPlaySongSelect()); + + AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); + AddAssert("retry count is 0", () => player.RestartCount == 0); + + AddStep("attempt to retry", () => player.ChildrenOfType().First().Action()); + AddUntilStep("wait for old player gone", () => Game.ScreenStack.CurrentScreen != player); + + AddUntilStep("get new player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); + AddAssert("retry count is 1", () => player.RestartCount == 1); + } + [Test] public void TestRetryFromResults() { @@ -51,9 +89,9 @@ namespace osu.Game.Tests.Visual.Navigation WorkingBeatmap beatmap() => Game.Beatmap.Value; - PushAndConfirm(() => new TestSongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); - AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(Game, virtualTrack: true).Wait()); + AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); @@ -61,7 +99,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press enter", () => InputManager.Key(Key.Enter)); AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); - AddStep("seek to end", () => beatmap().Track.Seek(beatmap().Track.Length)); + AddStep("seek to end", () => player.ChildrenOfType().First().Seek(beatmap().Track.Length)); AddUntilStep("wait for pass", () => (results = Game.ScreenStack.CurrentScreen as ResultsScreen) != null && results.IsLoaded); AddStep("attempt to retry", () => results.ChildrenOfType().First().Action()); AddUntilStep("wait for player", () => Game.ScreenStack.CurrentScreen != player && Game.ScreenStack.CurrentScreen is Player); @@ -75,7 +113,7 @@ namespace osu.Game.Tests.Visual.Navigation WorkingBeatmap beatmap() => Game.Beatmap.Value; - PushAndConfirm(() => new TestSongSelect()); + PushAndConfirm(() => new TestPlaySongSelect()); AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(Game, virtualTrack: true).Wait()); @@ -101,9 +139,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestMenuMakesMusic() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddUntilStep("wait for no track", () => Game.MusicController.CurrentTrack.IsDummyDevice); @@ -115,15 +153,15 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectWithClick() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); AddStep("Move mouse to backButton", () => InputManager.MoveMouseTo(backButtonPosition)); // BackButton handles hover using its child button, so this checks whether or not any of BackButton's children are hovered. - AddUntilStep("Back button is hovered", () => InputManager.HoveredDrawables.Any(d => d.Parent == Game.BackButton)); + AddUntilStep("Back button is hovered", () => Game.ChildrenOfType().First().Children.Any(c => c.IsHovered)); AddStep("Click back button", () => InputManager.Click(MouseButton.Left)); AddUntilStep("Overlay was hidden", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); @@ -175,9 +213,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestModSelectInput() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); @@ -196,9 +234,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestBeatmapOptionsInput() { - TestSongSelect songSelect = null; + TestPlaySongSelect songSelect = null; - PushAndConfirm(() => songSelect = new TestSongSelect()); + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); AddStep("Show options overlay", () => songSelect.BeatmapOptionsOverlay.Show()); @@ -214,6 +252,50 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Options overlay still visible", () => songSelect.BeatmapOptionsOverlay.State.Value == Visibility.Visible); } + [Test] + public void TestSettingsViaHotkeyFromMainMenu() + { + AddAssert("toolbar not displayed", () => Game.Toolbar.State.Value == Visibility.Hidden); + + AddStep("press settings hotkey", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.O); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddUntilStep("settings displayed", () => Game.Settings.State.Value == Visibility.Visible); + } + + [Test] + public void TestToolbarHiddenByUser() + { + AddStep("Enter menu", () => InputManager.Key(Key.Enter)); + + AddUntilStep("Wait for toolbar to load", () => Game.Toolbar.IsLoaded); + + AddStep("Hide toolbar", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.T); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + pushEscape(); + + AddStep("Enter menu", () => InputManager.Key(Key.Enter)); + + AddAssert("Toolbar is hidden", () => Game.Toolbar.State.Value == Visibility.Hidden); + + AddStep("Enter song select", () => + { + InputManager.Key(Key.Enter); + InputManager.Key(Key.Enter); + }); + + AddAssert("Toolbar is hidden", () => Game.Toolbar.State.Value == Visibility.Hidden); + } + private void pushEscape() => AddStep("Press escape", () => InputManager.Key(Key.Escape)); @@ -230,11 +312,13 @@ namespace osu.Game.Tests.Visual.Navigation ConfirmAtMainMenu(); } - private class TestSongSelect : PlaySongSelect + public class TestPlaySongSelect : PlaySongSelect { public ModSelectOverlay ModSelectOverlay => ModSelect; public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions; + + protected override bool DisplayStableImportPrompt => false; } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs index c0b77b580e..c1c968e862 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs @@ -15,8 +15,8 @@ namespace osu.Game.Tests.Visual.Navigation using (var config = new OsuConfigManager(LocalStorage)) { - config.Set(OsuSetting.Version, "2020.101.0"); - config.Set(OsuSetting.DisplayStarsMaximum, 10.0); + config.SetValue(OsuSetting.Version, "2020.101.0"); + config.SetValue(OsuSetting.DisplayStarsMaximum, 10.0); } } @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Navigation { AddAssert("config has migrated value", () => Precision.AlmostEquals(Game.LocalConfig.Get(OsuSetting.DisplayStarsMaximum), 10.1)); - AddStep("set value again", () => Game.LocalConfig.Set(OsuSetting.DisplayStarsMaximum, 10)); + AddStep("set value again", () => Game.LocalConfig.SetValue(OsuSetting.DisplayStarsMaximum, 10.0)); AddStep("force save config", () => Game.LocalConfig.Save()); diff --git a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs index 3d65e7e4ba..437c5b07c9 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs @@ -1,12 +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.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Overlays; +using osu.Game.Overlays.AccountCreation; +using osu.Game.Overlays.Settings; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -36,8 +40,6 @@ namespace osu.Game.Tests.Visual.Online [BackgroundDependencyLoader] private void load() { - API.Logout(); - localUser = API.LocalUser.GetBoundCopy(); localUser.BindValueChanged(user => { userPanelArea.Child = new UserGridPanel(user.NewValue) { Width = 200 }; }, true); } @@ -46,11 +48,14 @@ namespace osu.Game.Tests.Visual.Online public void TestOverlayVisibility() { AddStep("start hidden", () => accountCreation.Hide()); - AddStep("log out", API.Logout); + AddStep("log out", () => API.Logout()); AddStep("show manually", () => accountCreation.Show()); AddUntilStep("overlay is visible", () => accountCreation.State.Value == Visibility.Visible); + AddStep("click button", () => accountCreation.ChildrenOfType().Single().Click()); + AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + AddStep("log back in", () => API.Login("dummy", "password")); AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs index 1349264bf9..156d6b744e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs @@ -30,13 +30,14 @@ namespace osu.Game.Tests.Visual.Online ((DummyAPIAccess)API).HandleRequest = req => { - if (req is SearchBeatmapSetsRequest searchBeatmapSetsRequest) + if (!(req is SearchBeatmapSetsRequest searchBeatmapSetsRequest)) return false; + + searchBeatmapSetsRequest.TriggerSuccess(new SearchBeatmapSetsResponse { - searchBeatmapSetsRequest.TriggerSuccess(new SearchBeatmapSetsResponse - { - BeatmapSets = setsForResponse, - }); - } + BeatmapSets = setsForResponse, + }); + + return true; }; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs index cd2c4e9346..8818ac75b1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs @@ -63,13 +63,15 @@ namespace osu.Game.Tests.Visual.Online Builds = builds.Values.ToList() }; changelogRequest.TriggerSuccess(changelogResponse); - break; + return true; case GetChangelogBuildRequest buildRequest: if (requestedBuild != null) buildRequest.TriggerSuccess(requestedBuild); - break; + return true; } + + return false; }; Child = changelog = new TestChangelogOverlay(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index fca642ad6c..b13dd34ebc 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -11,6 +11,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Online.Chat; using osu.Game.Overlays; using osu.Game.Overlays.Chat.Selection; @@ -64,6 +66,24 @@ namespace osu.Game.Tests.Visual.Online }); } + [SetUpSteps] + public void SetUpSteps() + { + AddStep("register request handling", () => + { + ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case JoinChannelRequest _: + return true; + } + + return false; + }; + }); + } + [Test] public void TestHideOverlay() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs index c2a18330c9..cd22bb2513 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs @@ -85,9 +85,10 @@ namespace osu.Game.Tests.Visual.Online dummyAPI.HandleRequest = request => { if (!(request is GetCommentsRequest getCommentsRequest)) - return; + return false; getCommentsRequest.TriggerSuccess(commentBundle); + return true; }; }); diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs index 1baa07f208..30785fd163 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs @@ -12,15 +12,17 @@ using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Online.Spectator; using osu.Game.Overlays.Dashboard; -using osu.Game.Tests.Visual.Gameplay; +using osu.Game.Tests.Visual.Spectator; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { public class TestSceneCurrentlyPlayingDisplay : OsuTestScene { - [Cached(typeof(SpectatorStreamingClient))] - private TestSceneSpectator.TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSceneSpectator.TestSpectatorStreamingClient(); + private readonly User streamingUser = new User { Id = 2, Username = "Test user" }; + + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient testSpectatorClient = new TestSpectatorClient(); private CurrentlyPlayingDisplay currentlyPlaying; @@ -34,7 +36,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("add streaming client", () => { - nestedContainer?.Remove(testSpectatorStreamingClient); + nestedContainer?.Remove(testSpectatorClient); Remove(lookupCache); Children = new Drawable[] @@ -45,7 +47,7 @@ namespace osu.Game.Tests.Visual.Online RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - testSpectatorStreamingClient, + testSpectatorClient, currentlyPlaying = new CurrentlyPlayingDisplay { RelativeSizeAxes = Axes.Both, @@ -55,15 +57,15 @@ namespace osu.Game.Tests.Visual.Online }; }); - AddStep("Reset players", () => testSpectatorStreamingClient.PlayingUsers.Clear()); + AddStep("Reset players", () => testSpectatorClient.EndPlay(streamingUser.Id)); } [Test] public void TestBasicDisplay() { - AddStep("Add playing user", () => testSpectatorStreamingClient.PlayingUsers.Add(2)); + AddStep("Add playing user", () => testSpectatorClient.StartPlay(streamingUser.Id, 0)); AddUntilStep("Panel loaded", () => currentlyPlaying.ChildrenOfType()?.FirstOrDefault()?.User.Id == 2); - AddStep("Remove playing user", () => testSpectatorStreamingClient.PlayingUsers.Remove(2)); + AddStep("Remove playing user", () => testSpectatorClient.EndPlay(streamingUser.Id)); AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType().Any()); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs index 63bda08c88..3fc894da0d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs @@ -42,8 +42,11 @@ namespace osu.Game.Tests.Visual.Online ensureSoleilyRemoved(); createButtonWithBeatmap(createSoleily()); AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded); - AddStep("import soleily", () => beatmaps.Import(TestResources.GetTestBeatmapForImport())); + AddStep("import soleily", () => beatmaps.Import(TestResources.GetQuickTestBeatmapForImport())); + AddUntilStep("wait for beatmap import", () => beatmaps.GetAllUsableBeatmapSets().Any(b => b.OnlineBeatmapSetID == 241526)); + AddAssert("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable); + createButtonWithBeatmap(createSoleily()); AddAssert("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable); ensureSoleilyRemoved(); diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index 9bece39ca0..e8d9ff72af 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -51,7 +51,7 @@ namespace osu.Game.Tests.Visual.Online Username = "flyte", Id = 3103765, IsOnline = true, - CurrentModeRank = 1111, + Statistics = new UserStatistics { GlobalRank = 1111 }, Country = new Country { FlagName = "JP" }, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" }, @@ -60,7 +60,7 @@ namespace osu.Game.Tests.Visual.Online Username = "peppy", Id = 2, IsOnline = false, - CurrentModeRank = 2222, + Statistics = new UserStatistics { GlobalRank = 2222 }, Country = new Country { FlagName = "AU" }, CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", IsSupporter = true, diff --git a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs index 54e655d4ec..fc438ce6dd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -45,14 +46,14 @@ namespace osu.Game.Tests.Visual.Online switch (args.Action) { case NotifyCollectionChangedAction.Add: - args.NewItems.Cast().ForEach(mod => selectedMods.Add(new OsuSpriteText + args.NewItems.AsNonNull().Cast().ForEach(mod => selectedMods.Add(new OsuSpriteText { Text = mod.Acronym, })); break; case NotifyCollectionChangedAction.Remove: - args.OldItems.Cast().ForEach(mod => + args.OldItems.AsNonNull().Cast().ForEach(mod => { foreach (var selected in selectedMods) { diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs index 37d51c16d2..93a5b6fc59 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs @@ -2,7 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -14,32 +17,46 @@ namespace osu.Game.Tests.Visual.Online { private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; - private NewsOverlay news; + private NewsOverlay overlay; [SetUp] - public void SetUp() => Schedule(() => Child = news = new NewsOverlay()); + public void SetUp() => Schedule(() => Child = overlay = new NewsOverlay()); [Test] public void TestRequest() { setUpNewsResponse(responseExample); - AddStep("Show", () => news.Show()); - AddStep("Show article", () => news.ShowArticle("article")); + AddStep("Show", () => overlay.Show()); + AddStep("Show article", () => overlay.ShowArticle("article")); } - private void setUpNewsResponse(GetNewsResponse r) - => AddStep("set up response", () => + [Test] + public void TestCursorRequest() + { + setUpNewsResponse(responseWithCursor, "Set up cursor response"); + AddStep("Show", () => overlay.Show()); + AddUntilStep("Show More button is visible", () => showMoreButton?.Alpha == 1); + setUpNewsResponse(responseWithNoCursor, "Set up no cursor response"); + AddStep("Click Show More", () => showMoreButton?.Click()); + AddUntilStep("Show More button is hidden", () => showMoreButton?.Alpha == 0); + } + + private ShowMoreButton showMoreButton => overlay.ChildrenOfType().FirstOrDefault(); + + private void setUpNewsResponse(GetNewsResponse r, string testName = "Set up response") + => AddStep(testName, () => { dummyAPI.HandleRequest = request => { if (!(request is GetNewsRequest getNewsRequest)) - return; + return false; getNewsRequest.TriggerSuccess(r); + return true; }; }); - private GetNewsResponse responseExample => new GetNewsResponse + private static GetNewsResponse responseExample => new GetNewsResponse { NewsPosts = new[] { @@ -61,5 +78,37 @@ namespace osu.Game.Tests.Visual.Online } } }; + + private static GetNewsResponse responseWithCursor => new GetNewsResponse + { + NewsPosts = new[] + { + new APINewsPost + { + Title = "This post has an image which starts with \"/\" and has many authors!", + Preview = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + Author = "someone, someone1, someone2, someone3, someone4", + FirstImage = "/help/wiki/shared/news/banners/monthly-beatmapping-contest.png", + PublishedAt = DateTimeOffset.Now + } + }, + Cursor = new Cursor() + }; + + private static GetNewsResponse responseWithNoCursor => new GetNewsResponse + { + NewsPosts = new[] + { + new APINewsPost + { + Title = "This post has a full-url image! (HTML entity: &)", + Preview = "boom (HTML entity: &)", + Author = "user (HTML entity: &)", + FirstImage = "https://assets.ppy.sh/artists/88/header.jpg", + PublishedAt = DateTimeOffset.Now + } + }, + Cursor = null + }; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsSidebar.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsSidebar.cs new file mode 100644 index 0000000000..b000553a7b --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsSidebar.cs @@ -0,0 +1,152 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.News.Sidebar; +using static osu.Game.Overlays.News.Sidebar.YearsPanel; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneNewsSidebar : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private TestNewsSidebar sidebar; + + [SetUp] + public void SetUp() => Schedule(() => Child = sidebar = new TestNewsSidebar { YearChanged = onYearChanged }); + + [Test] + public void TestBasic() + { + AddStep("Add metadata", () => sidebar.Metadata.Value = getMetadata(2021)); + AddUntilStep("Month sections exist", () => sidebar.ChildrenOfType().Any()); + } + + [Test] + public void TestMetadataWithNoPosts() + { + AddStep("Add data with no posts", () => sidebar.Metadata.Value = metadata_with_no_posts); + AddUntilStep("No month sections were created", () => !sidebar.ChildrenOfType().Any()); + } + + [Test] + public void TestYearsPanelVisibility() + { + AddUntilStep("Years panel is hidden", () => yearsPanel?.Alpha == 0); + AddStep("Add data", () => sidebar.Metadata.Value = getMetadata(2021)); + AddUntilStep("Years panel is visible", () => yearsPanel?.Alpha == 1); + } + + private void onYearChanged(int year) => sidebar.Metadata.Value = getMetadata(year); + + private YearsPanel yearsPanel => sidebar.ChildrenOfType().FirstOrDefault(); + + private APINewsSidebar getMetadata(int year) => new APINewsSidebar + { + CurrentYear = year, + Years = new[] + { + 2021, + 2020, + 2019, + 2018, + 2017, + 2016, + 2015, + 2014, + 2013 + }, + NewsPosts = new List + { + new APINewsPost + { + Title = "(Mar) Short title", + PublishedAt = new DateTime(year, 3, 1) + }, + new APINewsPost + { + Title = "(Mar) Oh boy that's a long post title I wonder if it will break anything", + PublishedAt = new DateTime(year, 3, 1) + }, + new APINewsPost + { + Title = "(Mar) Medium title, nothing to see here", + PublishedAt = new DateTime(year, 3, 1) + }, + new APINewsPost + { + Title = "(Feb) Short title", + PublishedAt = new DateTime(year, 2, 1) + }, + new APINewsPost + { + Title = "(Feb) Oh boy that's a long post title I wonder if it will break anything", + PublishedAt = new DateTime(year, 2, 1) + }, + new APINewsPost + { + Title = "(Feb) Medium title, nothing to see here", + PublishedAt = new DateTime(year, 2, 1) + }, + new APINewsPost + { + Title = "Short title", + PublishedAt = new DateTime(year, 1, 1) + }, + new APINewsPost + { + Title = "Oh boy that's a long post title I wonder if it will break anything", + PublishedAt = new DateTime(year, 1, 1) + }, + new APINewsPost + { + Title = "Medium title, nothing to see here", + PublishedAt = new DateTime(year, 1, 1) + } + } + }; + + private static readonly APINewsSidebar metadata_with_no_posts = new APINewsSidebar + { + CurrentYear = 2021, + Years = new[] + { + 2021, + 2020, + 2019, + 2018, + 2017, + 2016, + 2015, + 2014, + 2013 + }, + NewsPosts = Array.Empty() + }; + + private class TestNewsSidebar : NewsSidebar + { + public Action YearChanged; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Metadata.BindValueChanged(metadata => + { + foreach (var b in this.ChildrenOfType()) + b.Action = () => YearChanged?.Invoke(b.Year); + }, true); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index 01e67b1681..165fff99dd 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -83,6 +83,28 @@ namespace osu.Game.Tests.Visual.Online }; }); + [Test] + public void TestSystemMessageOrdering() + { + var standardMessage = new Message(messageIdSequence++) + { + Sender = admin, + Content = "I am a wang!" + }; + + var infoMessage1 = new InfoMessage($"the system is calling {messageIdSequence++}"); + var infoMessage2 = new InfoMessage($"the system is calling {messageIdSequence++}"); + + AddStep("message from admin", () => testChannel.AddNewMessages(standardMessage)); + AddStep("message from system", () => testChannel.AddNewMessages(infoMessage1)); + AddStep("message from system", () => testChannel.AddNewMessages(infoMessage2)); + + AddAssert("message order is correct", () => testChannel.Messages.Count == 3 + && testChannel.Messages[0] == standardMessage + && testChannel.Messages[1] == infoMessage1 + && testChannel.Messages[2] == infoMessage2); + } + [Test] public void TestManyMessages() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserHistoryGraph.cs b/osu.Game.Tests/Visual/Online/TestSceneUserHistoryGraph.cs index 57ce4c41e7..484c59695e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserHistoryGraph.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserHistoryGraph.cs @@ -19,13 +19,12 @@ namespace osu.Game.Tests.Visual.Online { UserHistoryGraph graph; - Add(graph = new UserHistoryGraph + Add(graph = new UserHistoryGraph("Test") { RelativeSizeAxes = Axes.X, Height = 200, Anchor = Anchor.Centre, Origin = Anchor.Centre, - TooltipCounterName = "Test" }); var values = new[] diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs new file mode 100644 index 0000000000..863fa48ddf --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Wiki; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneWikiHeader : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Orange); + + [Cached] + private readonly Bindable wikiPageData = new Bindable(new APIWikiPage + { + Title = "Main Page", + Path = "Main_Page", + }); + + private TestHeader header; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = header = new TestHeader + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ShowIndexPage = dummyShowIndexPage, + ShowParentPage = dummyShowParentPage, + }; + wikiPageData.BindTo(header.WikiPageData); + }); + + [Test] + public void TestWikiHeader() + { + AddAssert("Current is index", () => checkCurrent("index")); + + AddStep("Change wiki page data", () => wikiPageData.Value = new APIWikiPage + { + Title = "Welcome", + Path = "Welcome" + }); + AddAssert("Current is welcome", () => checkCurrent("Welcome")); + AddAssert("Check breadcrumb", checkBreadcrumb); + + AddStep("Change current to index", () => header.Current.Value = "index"); + AddAssert("Current is index", () => checkCurrent("index")); + + AddStep("Change wiki page data", () => wikiPageData.Value = new APIWikiPage + { + Title = "Developers", + Path = "People/The_Team/Developers", + Subtitle = "The Team", + }); + AddAssert("Current is 'Developers'", () => checkCurrent("Developers")); + AddAssert("Check breadcrumb", checkBreadcrumb); + + AddStep("Change current to 'The Team'", () => header.Current.Value = "The Team"); + AddAssert("Current is 'The Team'", () => checkCurrent("The Team")); + AddAssert("Check breadcrumb", checkBreadcrumb); + } + + private bool checkCurrent(string expectedCurrent) => header.Current.Value == expectedCurrent; + + private bool checkBreadcrumb() + { + var result = header.TabControlItems.Contains(wikiPageData.Value.Title); + + if (wikiPageData.Value.Subtitle != null) + result = header.TabControlItems.Contains(wikiPageData.Value.Subtitle) && result; + + return result; + } + + private void dummyShowIndexPage() => wikiPageData.SetDefault(); + + private void dummyShowParentPage() + { + wikiPageData.Value = new APIWikiPage + { + Path = "People/The_Team", + Title = "The Team", + Subtitle = "People" + }; + } + + private class TestHeader : WikiHeader + { + public IReadOnlyList TabControlItems => TabControl.Items; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs new file mode 100644 index 0000000000..3a2bafb128 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMainPage.cs @@ -0,0 +1,43 @@ +// 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.Game.Overlays; +using osu.Game.Overlays.Wiki; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneWikiMainPage : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Orange); + + public TestSceneWikiMainPage() + { + Children = new Drawable[] + { + new Box + { + Colour = overlayColour.Background5, + RelativeSizeAxes = Axes.Both, + }, + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20), + Child = new WikiMainPage + { + Markdown = main_page_markdown + } + } + }; + } + + // From https://osu.ppy.sh/api/v2/wiki/en/Main_Page + private const string main_page_markdown = + "---\nlayout: main_page\n---\n\n\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 new file mode 100644 index 0000000000..9d8f07969c --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -0,0 +1,175 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Markdig.Syntax.Inlines; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers.Markdown; +using osu.Game.Overlays; +using osu.Game.Overlays.Wiki.Markdown; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneWikiMarkdownContainer : OsuTestScene + { + private TestMarkdownContainer markdownContainer; + + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Orange); + + [SetUp] + public void Setup() => Schedule(() => + { + Children = new Drawable[] + { + new Box + { + Colour = overlayColour.Background5, + RelativeSizeAxes = Axes.Both, + }, + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20), + Child = markdownContainer = new TestMarkdownContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + }; + }); + + [Test] + public void TestLink() + { + 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 '../FAQ''", () => markdownContainer.Text = "[FAQ](../FAQ)"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/FAQ"); + + AddStep("set './Writing''", () => markdownContainer.Text = "[wiki writing guidline](./Writing)"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Writing"); + + AddStep("set 'Formatting''", () => markdownContainer.Text = "[wiki formatting guidline](Formatting)"); + AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Formatting"); + } + + [Test] + public void TestOutdatedNoticeBox() + { + AddStep("Add outdated yaml header", () => + { + markdownContainer.Text = @"--- +outdated: true +---"; + }); + } + + [Test] + public void TestNeedsCleanupNoticeBox() + { + AddStep("Add needs cleanup yaml header", () => + { + markdownContainer.Text = @"--- +needs_cleanup: true +---"; + }); + } + + [Test] + public void TestOnlyShowOutdatedNoticeBox() + { + AddStep("Add outdated and needs cleanup yaml", () => + { + markdownContainer.Text = @"--- +outdated: true +needs_cleanup: true +---"; + }); + } + + [Test] + public void TestAbsoluteImage() + { + AddStep("Add absolute image", () => + { + markdownContainer.DocumentUrl = "https://dev.ppy.sh"; + markdownContainer.Text = "![intro](/wiki/Interface/img/intro-screen.jpg)"; + }); + } + + [Test] + public void TestRelativeImage() + { + AddStep("Add relative image", () => + { + markdownContainer.DocumentUrl = "https://dev.ppy.sh"; + markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Interface/"; + markdownContainer.Text = "![intro](img/intro-screen.jpg)"; + }); + } + + [Test] + public void TestBlockImage() + { + AddStep("Add paragraph with block image", () => + { + markdownContainer.DocumentUrl = "https://dev.ppy.sh"; + markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Interface/"; + markdownContainer.Text = @"Line before image + +![play menu](img/play-menu.jpg ""Main Menu in osu!"") + +Line after image"; + }); + } + + [Test] + public void TestInlineImage() + { + AddStep("Add inline image", () => + { + markdownContainer.DocumentUrl = "https://dev.ppy.sh"; + markdownContainer.Text = "![osu! mode icon](/wiki/shared/mode/osu.png) osu!"; + }); + } + + private class TestMarkdownContainer : WikiMarkdownContainer + { + public LinkInline Link; + + public new string DocumentUrl + { + set => base.DocumentUrl = value; + } + + public override MarkdownTextFlowContainer CreateTextFlow() => new TestMarkdownTextFlowContainer + { + UrlAdded = link => Link = link, + }; + + private class TestMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer + { + public Action UrlAdded; + + protected override void AddLinkText(string text, LinkInline linkInline) + { + base.AddLinkText(text, linkInline); + + UrlAdded?.Invoke(linkInline); + } + + protected override void AddImage(LinkInline linkInline) => AddDrawable(new WikiMarkdownImage(linkInline)); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs new file mode 100644 index 0000000000..da4bf82948 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs @@ -0,0 +1,72 @@ +// 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.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneWikiOverlay : OsuTestScene + { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private WikiOverlay wiki; + + [SetUp] + public void SetUp() => Schedule(() => Child = wiki = new WikiOverlay()); + + [Test] + public void TestMainPage() + { + setUpWikiResponse(responseMainPage); + AddStep("Show Main Page", () => wiki.Show()); + } + + [Test] + public void TestArticlePage() + { + setUpWikiResponse(responseArticlePage); + AddStep("Show Article Page", () => wiki.ShowPage("Interface")); + } + + private void setUpWikiResponse(APIWikiPage r) + => AddStep("set up response", () => + { + dummyAPI.HandleRequest = request => + { + if (!(request is GetWikiRequest getWikiRequest)) + return false; + + getWikiRequest.TriggerSuccess(r); + return true; + }; + }); + + // 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", + Locale = "en", + Subtitle = null, + Markdown = + "---\nlayout: main_page\n---\n\n\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", + }; + + // From https://osu.ppy.sh/api/v2/wiki/en/Interface + private APIWikiPage responseArticlePage => new APIWikiPage + { + Title = "Interface", + Layout = "markdown_page", + Path = "Interface", + Locale = "en", + Subtitle = null, + Markdown = + "# Interface\n\n![](img/intro-screen.jpg \"Introduction screen\")\n\n## Main Menu\n\n![](img/main-menu.jpg \"Main Menu\")\n\nThe [osu!cookie](/wiki/Glossary#cookie) \\[1\\] pulses according to the [BPM](/wiki/Beatmapping/Beats_per_minute) of any song currently playing on the main menu. In addition, bars will extend out of the osu!cookie in accordance to the song's volume. If no song is playing, it pulses at a slow 60 BPM. The elements of the main menu are as follows:\n\n- \\[2\\] Click Play (`P`) or the logo to switch to the Solo mode song selection screen.\n- \\[3\\] Click Edit (`E`) to open the Editor mode song selection screen.\n- \\[4\\] Click Options (`O`) to go to the Options screen.\n- \\[5\\] Click Exit (`Esc`) to exit osu!.\n- \\[6\\] A random useful tip is displayed below the menu.\n- \\[7\\] In the lower-left is a link to the osu! website, as well as copyright information.\n- \\[8\\] Connection result to [Bancho](/wiki/Glossary#bancho)! In this picture it is not shown, but the connection result looks like a chain link.\n- \\[9\\] In the bottom right are the chat controls for the extended [chat window](/wiki/Chat_Console) (called \"Player List\" here) and the regular chat window (`F9` & `F8`, respectively).\n- \\[10\\] In the upper right is the osu! jukebox which plays the songs in random order. The top shows the song currently playing. The buttons, from left to right, do as follows:\n - Previous Track\n - Play\n - Pause\n - Stop (the difference between Play and Stop is that Stop will reset the song to the beginning, while Pause simply pauses it)\n - Next Track\n - View Song Info. This toggles the top bar showing the song info between being permanent and temporary. When permanent, the info bar will stay visible until it fades out with the rest of the UI. When temporary, it will disappear a little while after a song has been chosen. It will stay hidden until it is toggled again, or another song plays.\n- \\[11\\] The number of beatmaps you have available, how long your osu!client has been running, and your system clock.\n- \\[12\\] Your profile, click on it to display the User Options (see below).\n\n## User Options\n\n![](img/user-options.jpg \"User Options\")\n\nAccess this screen by clicking your profile at the top left of the main menu. You cannot access the Chat Consoles while viewing the user option screen. You can select any item by pressing the corresponding number on the option:\n\n1. `View Profile`: Opens up your profile page in your default web browser.\n2. `Sign Out`: Sign out of your account (after signing out, the [Options](/wiki/Options) sidebar will prompt you to sign in).\n3. `Change Avatar`: Open up the edit avatar page in your default web browser.\n4. `Close`: Close this dialog\n\n## Play Menu\n\n![](img/play-menu.jpg \"Play Menu\")\n\n- Click `Solo` (`P`) to play alone.\n- Click `Multi` (`M`) to play with other people. You will be directed to the [Multi](/wiki/Multi) Lobby (see below).\n- Click `Back` to return to the main menu.\n\n## Multi Lobby\n\n*Main page: [Multi](/wiki/Multi)*\n\n![](img/multi-lobby.jpg \"Multi Lobby\")\n\n![](img/multi-room.jpg \"Multi Host\")\n\n1. Your rank in the match. This is also shown next to your name.\n2. Your profile information.\n3. The jukebox.\n4. Player list - displays player names, their rank (host or player), their [mods](/wiki/Game_modifier) activated (if any, see \\#7), their osu! ranking, and their team (if applicable).\n5. The name of the match and the password settings.\n6. The beatmap selected. It shows the beatmap as it would in the solo song selection screen.\n7. The [mods](/wiki/Game_modifier) that you have activated (see #12), as well as the option to select them. The option marked \"Free Mods\" toggles whether or not players can select their own mods. If yes, they can pick any combination of mods *except for speed-altering mods like [Double Time](/wiki/Game_modifier/Double_Time)*. If no, the host decides what mods will be used. The host can pick speed-altering mods regardless of whether or not Free Mods is turned on.\n8. The team mode and win conditions.\n9. The ready button.\n10. The [chat console](/wiki/Chat_Console).\n11. The leave button.\n12. Where your activated mods appear.\n\n## Song Selection Screen\n\n![](img/song-selection.jpg \"Song Selection\")\n\nYou can identify the current mode selected by either looking at the icon in the bottom left, above Mode, or by looking at the transparent icon in the center of the screen. These are the four you will see:\n\n- ![](/wiki/shared/mode/osu.png) is [osu!](/wiki/Game_mode/osu!)\n- ![](/wiki/shared/mode/taiko.png) is [osu!taiko](/wiki/Game_mode/osu!taiko)\n- ![](/wiki/shared/mode/catch.png) is [osu!catch](/wiki/Game_mode/osu!catch)\n- ![](/wiki/shared/mode/mania.png) is [osu!mania](/wiki/Game_mode/osu!mania)\n\nBefore continuing on, this screen has too many elements to note with easily, noticeable numbers. The subsections below will focus on one part of the screen at a time, starting from the top down and left to right.\n\n### Beatmap Information\n\n![](img/metadata-comparison.jpg)\n\n![](img/beatmap-metadata.jpg)\n\nThis area displays **information on the beatmap difficulty currently selected.** By default, the beatmap whose song is heard in the osu! jukebox is selected when entering the selection screen. In the top left is the ranked status of the beatmap. The title is next. Normally, the romanised title is shown, but if you select `Prefer metadata in original language` in the [Options](/wiki/Options), it will show the Unicode title; this is shown in the upper picture. The beatmapper is also shown, and beatmap information is shown below. From left to right, the values are as follows:\n\n- **Length**: The total length of the beatmap, from start to finish and including breaks. Not to be confused with [drain time](/wiki/Glossary#drain-time).\n- **BPM**: The BPM of the beatmap. If (like in the lower picture) there are two BPMS and one in parentheses, this means that the BPM changes throughout the song. It shows the slowest and fastest BPMs, and the value in parentheses is the BPM at the start of the beatmap.\n- **Objects**: The total amount of [hit objects](/wiki/Hit_Objects) in the beatmap.\n- **Circles**: The total amount of hit circles in the beatmap.\n- **Sliders**: The total amount of sliders in the beatmap.\n- **Spinners**: The total amount of spinners in the beatmap.\n- **OD**: The Overall Difficulty of the beatmap.\n- **HP**: The drain rate of your HP. In osu!, this is how much of an HP loss you receive upon missing a note, how fast the life bar idly drains, and how much HP is received for hitting a note. In osu!mania, this is the same except there is no idle HP drain. In osu!taiko, this determines how slowly the HP bar fills and how much HP is lost when a note is missed. osu!catch is the same as osu!.\n- **Stars**: The star difficulty of the beatmap. This is graphically visible in the beatmap rectangle itself.\n\n### Group and Sort\n\n![](img/beatmap-filters.jpg)\n\nClick on one of the tabs to **sort your song list according to the selected criterion**.\n\n**Group** - Most options organize beatmaps into various expandable groups:\n\n- `No grouping` - Beatmaps will not be grouped but will still be sorted in the order specified by Sort.\n- `By Difficulty` - Beatmaps will be grouped by their star difficulty, rounded to the nearest whole number.\n- `By Artist` - Beatmaps will be grouped by the artist's first character of their name.\n- `Recently Played` - Beatmaps will be grouped by when you last played them.\n- `Collections` - This will show the collections you have created. *Note that this will hide beatmaps not listed in a collection!*\n- `By BPM` - Beatmaps will be grouped according to BPM in multiples of 60, starting at 120.\n- `By Creator` - Beatmaps will be grouped by the beatmap creator's name's first character.\n- `By Date Added` - Beatmaps will be grouped according to when they were added, from today to 4+ months ago.\n- `By Length` - Beatmaps will be grouped according to their length: 1 minute or less, 2 minutes or less, 3, 4, 5, and 10.\n- `By Mode` - Beatmaps will be grouped according to their game mode.\n- `By Rank Achieved` - Beatmaps will be sorted by the highest rank achieved on them.\n- `By Title` - Beatmaps will be grouped by the first letter of their title.\n- `Favourites` - Only beatmaps you have favorited online will be shown.\n- `My Maps` - Only beatmaps you have mapped (that is, whose creator matches your profile name) will be shown.\n- `Ranked Status` - Beatmaps will be grouped by their ranked status: ranked, pending, not submitted, unknown, or loved.\n\nThe first five groupings are available in tabs below Group and Sort.\n\n**Sort** - Sorts beatmaps in a certain order\n\n- `By Artist` - Beatmaps will be sorted alphabetically by the artist's name's first character.\n- `By BPM` - Beatmaps will be sorted lowest to highest by their BPM. For maps with multiple BPMs, the highest will be used.\n- `By Creator` - Beatmaps will be sorted alphabetically by the creator's name's first character.\n- `By Date Added` - Beatmaps will be sorted from oldest to newest by when they were added.\n- `By Difficulty` - Beatmaps will be sorted from easiest to hardest by star difficulty. *Note that this will split apart mapsets!*\n- `By Length` - Beatmaps will be sorted from shortest to longest by length.\n- `By Rank Achieved` - Beatmaps will be sorted from poorest to best by the highest rank achieved on them.\n- `By Title` - Beatmaps will be sorted alphabetically by the first character of their name.\n\n### Search\n\n![](img/search-bar.jpg)\n\n*Note: You cannot have the chat console or the options sidebar open if you want to search; otherwise, anything you type will be perceived as chat text or as an options search query.*\n\nOnly beatmaps that match the criteria of your search will be shown. By default, any search will be matched against the beatmaps' artists, titles, creators, and tags.\n\nIn addition to searching these fields, you can use filters to search through other metadata by combining one of the supported filters with a comparison to a value (for example, `ar=9`).\n\nSupported filters:\n\n- `artist`: Name of the artist\n- `creator`: Name of the beatmap creator\n- `ar`: Approach Rate\n- `cs`: Circle Size\n- `od`: Overall Difficulty\n- `hp`: HP Drain Rate\n- `keys`: Number of keys (osu!mania and converted beatmaps only)\n- `stars`: Star Difficulty\n- `bpm`: Beats per minute\n- `length`: Length in seconds\n- `drain`: Drain Time in seconds\n- `mode`: Mode. Value can be `osu`, `taiko`, `catchthebeat`, or `mania`, or `o`/`t`/`c`/`m` for short.\n- `status`: Ranked status. Value can be `ranked`, `approved`, `pending`, `notsubmitted`, `unknown`, or `loved`, or `r`/`a`/`p`/`n`/`u`/`l` for short.\n- `played`: Time since last played in days\n- `unplayed`: Shows only unplayed maps. A comparison with no set value must be used. The comparison itself is ignored.\n- `speed`: Saved osu!mania scroll speed. Always 0 for unplayed maps or if the [Remember osu!mania scroll speed per beatmap](/wiki/Options#gameplay) option is off\n\nSupported comparisons:\n\n- `=` or `==`: Equal to\n- `!=`: Not equal to\n- `<`: Less than\n- `>`: Greater than\n- `<=`: Less than or equal to\n- `>=`: Greater than or equal to\n\nYou may also enter a beatmap or beatmapset ID in your search to get a single result.\n\n### Rankings\n\n![](img/leaderboards.jpg)\n\n A variety of things can appear in this space:\n\n- A \"Not Submitted\" box denotes a beatmap that has not been uploaded to the osu! site using the Beatmap Submission System or was deleted by the mapper.\n- An \"Update to latest version\" box appears if there is a new version of the beatmap available for download. Click on the button to update.\n - **Note:** Once you update the beatmap, it cannot be reversed. If you want to preserve the older version for some reason (say, to keep scores), then do not update.\n- A \"Latest pending version\" box appears means that the beatmap has been uploaded to the osu!website but is not ranked yet.\n- If replays matching the view setting of the beatmap exist, they will be displayed instead of a box denoting the ranked/played status of the beatmap. This is shown in the above picture.\n - Under public rankings (e.g. Global, Friends, etc.), your high score will be shown at the bottom, as well as your rank on the leaderboard.\n- A \"No records set!\" box means that there are no replays for the current view setting (this is typically seen in the Local view setting if you just downloaded or edited the beatmap).\n - Note: Scores for Multi are not counted as records.\n\nThese are the view settings:\n\n- Local Ranking\n- Country Ranking\\*\n- Global Ranking\n- Global Ranking (Selected Mods)\\*\n- Friend Ranking\\*\n\n\\*Requires you to be an [osu!supporter](/wiki/osu!supporter) to access them.\n\nClick the word bubble icon to call up the **Quick Web Access** screen for the selected beatmap:\n\n- Press `1` or click the `Beatmap Listing/Scores` button and your default internet browser will pull up the Beatmap Listing and score page of the beatmap set the selected beatmap belongs to.\n- Press `2` or click `Beatmap Modding` and your default internet browser will pull up the modding page of the beatmap set the selected beatmap belongs to.\n- Press `3` or `Esc` or click `Cancel` to return to the Song Selection Screen.\n\nWhile you are on the Quick Web Access Screen, you cannot access the Chat and Extended Chat Consoles.\n\n### Song\n\n![](img/beatmap-cards.jpg)\n\nThe song list displays all available beatmaps. Different beatmaps may have different coloured boxes:\n\n- **Pink**: This beatmap has not been played yet.\n- **Orange**: At least one beatmap from the beatmapset has been completed.\n- **Light Blue**: Other beatmaps in the same set, shown when a mapset is expanded.\n- **White**: Currently selected beatmap.\n\nYou can navigate the beatmap list by using the mouse wheel, using the up and down arrow keys, dragging it while holding the left mouse button or clicking the right mouse button (previously known as Absolute Scrolling), which will move the scroll bar to your mouse's Y position. Click on a box to select that beatmap and display its information on the upper left, high scores (if any) on the left and, if you've cleared it, the letter grade of the highest score you've achieved. Click the box again, press `Enter` or click the osu!cookie at the lower right to begin playing the beatmap.\n\n### Gameplay toolbox\n\n![](img/game-mode-selector.jpg \"List of available game modes\")\n\n![](img/gameplay-toolbox.jpg)\n\nThis section can be called the gameplay toolbox. We will cover each button's use from left to right.\n\nPress `Esc` or click the `Back` button to return to main menu.\n\nClick on the `Mode` button to open up a list of gameplay modes available on osu!. Click on your desired gameplay mode and osu! will switch to that gameplay mode style - the scoreboard will change accordingly. Alternatively, you can press `Ctrl` and `1` (osu!), `2` (osu!taiko), `3` (osu!catch), or `4` (osu!mania) to change the gamemode.\n\nThe background transparent icon and the \"Mode\" box will change to depict what mode is currently selected.\n\n![](img/game-modifiers.jpg \"Mod Selection Screen\")\n\nClick the `Mods` button or press `F1` to open the **[Mod Selection Screen](/wiki/Game_modifier)**.\n\nIn this screen, you can apply modifications (\"mods\" for short) to gameplay. Some mods lower difficulty and apply a multiplier that lowers the score you achieve. Conversely, some mods increase the difficulty, but apply a multiplier that increases the score you achieve. Finally, some mods modify gameplay in a different way. [Relax](/wiki/Game_modifier/Relax) and [Auto Pilot](/wiki/Game_modifier/Autopilot) fall in that category.\n\nPlace your mouse on a mod's icon to see a short description of its effect. Click on an icon to select or deselect that mod. Some mods, like Double Time, have multiple variations; click on the mod again to cycle through. The score multiplier value displays the combined effect the multipliers of the mod(s) of you have selected will have on your score. Click `Reset all mods` or press `1` to deselect all currently selected mods. Click `Close` or press `2` or `Esc` to return to the Song Selection Screen.\n\nWhile you are on the Mod Selection Screen, you cannot access the Chat and Extended Chat Consoles. In addition, skins can alter the text and/or icon of the mods, but the effects will still be the same.\n\nClick the `Random` button or press `F2` to have the game **randomly scroll through all of your beatmaps and pick one.** You cannot select a beatmap yourself until it has finished scrolling.\n\n*Note: You can press `Shift` + the `Random` button or `F2` to go back to the beatmap you had selected before you randomized your selection.*\n\n![](img/beatmap-options.jpg \"Possible commands for a beatmap\")\n\nClick the `Beatmap Options` button, press `F3` or right-click your mouse while hovering over the beatmap to call up the **Beatmap Options Menu for options on the currently selected beatmap**.\n\n- Press `1` or click the `Manage Collections` button to bring up the Collections screen - here, you can manage pre-existing collections, as well as add or remove the currently selected beatmap or mapset to or from a collection.\n- Press `2` or click `Delete...` to delete the \\[1\\] currently selected beatmapset, \\[2\\] delete the currently selected beatmap, or \\[3\\] delete **all VISIBLE beatmaps**.\n - Note that deleted beatmaps are moved to the Recycle Bin.\n- Press `3` or click `Remove from Unplayed` to mark an unplayed beatmap as played (that is, change its box colour from pink to orange).\n- Press `4` or click `Clear local scores` to delete all records of the scores you have achieved in this beatmap.\n- Press `5` or click `Edit` to open the selected beatmap in osu!'s Editor.\n- Press `6` or `Esc` or click `Close` to return to the Song Selection Screen.\n\nClick on **your user panel** to access the **User Options Menu**.\n\nClick the **[osu!cookie](/wiki/Glossary#cookie)** to **start playing the selected beatmap**.\n\n## Results screen\n\n![](img/results-osu.jpg \"Accuracy in osu!\")\n\nThis is the results screen shown after you have successfully passed the beatmap. You can access your online results by scrolling down or pressing the obvious button.\n\n**Note:** The results screen may change depending on the used skin.\n\nBelow are the results screens of the other game modes.\n\n![](img/results-taiko.jpg \"Accuracy in osu!taiko\")\n\n![](img/results-mania.jpg \"Accuracy in osu!mania\")\n\n![](img/results-catch.jpg \"Accuracy in osu!catch\")\n\n### Online Leaderboard\n\n![](img/extended-results-screen.jpg \"An example of an osu!online score\")\n\nThis is your online leaderboard. You can go here by scrolling down from the results screen. Your Local Scoreboard will show your name and the score as usual.\n\n1. Your player bar. It shows your [PP](/wiki/Performance_Points), Global Rank, Total Score, Overall [Accuracy](/wiki/Accuracy), and level bar.\n2. `Save replay to Replays folder`: You can watch the replay later either by opening it from a local leaderboard, or by going to `Replays` directory and double clicking it.\n3. `Add as online favourite`: Include the beatmap into your list of favourites, which is located on your osu! profile page under the \"Beatmaps\" section.\n4. Local Leaderboard: All your results are stored on your computer. To see them, navigate to the [song selection screen](#song-selection-screen), then select `Local Rankings` from the drop-down menu on the left.\n5. `Beatmap Ranking` section. Available only for maps with online leaderboards ([qualified](/wiki/Beatmap/Category#qualified), [ranked](/wiki/Beatmap/Category#ranked), or [loved](/wiki/Beatmap/Category#loved)). You also need to be online to see this section.\n 1. `Overall`: Your position on the map's leaderboard, where you compete against players that used [mods](/wiki/Game_modifier), even if you didn't use any yourself.\n 2. `Accuracy`: How [precisely](/wiki/Accuracy) did you play the beatmap. Will only be counted when your old score is surpassed.\n 3. `Max Combo`: Your longest combo on the map you played.\n 4. `Ranked Score`: Your [best result](/wiki/Score#ranked-score) on the beatmap.\n 5. `Total Score`: Not taken into account, since it does not affect your position in online rankings.\n 6. `Performance`: The amount of [unweighted PP](/wiki/Performance_points#why-didnt-i-gain-the-full-amount-of-pp-from-a-map-i-played) you would receive for the play.\n6. `Overall Ranking` section. It's available only for beatmaps with online leaderboards. You also need to be online to see this section.\n 1. `Overall`: Your global ranking in the world.\n 2. `Accuracy`: Your average [accuracy](/wiki/Accuracy#accuracy) over all beatmaps you have played.\n 3. `Max Combo`: The longest combo over all beatmaps you have played.\n 4. [`Ranked Score`](/wiki/Score#ranked-score): The number of points earned from all ranked beatmaps that you have ever played, with every map being counted exactly once.\n 5. [`Total Score`](/wiki/Score#total-score): Same as ranked score, but it takes into account all beatmaps available on the osu! website, and also underplayed or failed beatmaps. This counts towards your level.\n 6. `Perfomance`: Displays your total amount of Performance Points, and also how many PP the submitted play was worth.\n7. Information about the beatmap with its playcount and pass rate.\n8. Beatmap rating. Use your personal discretion based on whether you enjoy the beatmap or not. Best left alone if you can't decide.\n9. Click here to return to the song selection screen.\n\n![](img/medal-unlock.jpg \"Unlocking a medal\")\n\nAbove is what it looks like to receive a medal.\n", + }; + } +} diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs index 8dd81e02e2..255f147ec9 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Playlists Room.RecentParticipants.Add(new User { Username = "peppy", - CurrentModeRank = 1234, + Statistics = new UserStatistics { GlobalRank = 1234 }, Id = 2 }); } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index cdcded8f61..61d49e4018 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net; -using System.Threading.Tasks; using JetBrains.Annotations; using Newtonsoft.Json.Linq; using NUnit.Framework; @@ -76,7 +75,7 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind user score info handler", () => { userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; - bindHandler(3000, userScore); + bindHandler(true, userScore); }); createResults(() => userScore); @@ -89,7 +88,7 @@ namespace osu.Game.Tests.Visual.Playlists [Test] public void TestShowNullUserScoreWithDelay() { - AddStep("bind delayed handler", () => bindHandler(3000)); + AddStep("bind delayed handler", () => bindHandler(true)); createResults(); waitForDisplay(); @@ -103,7 +102,7 @@ namespace osu.Game.Tests.Visual.Playlists createResults(); waitForDisplay(); - AddStep("bind delayed handler", () => bindHandler(3000)); + AddStep("bind delayed handler", () => bindHandler(true)); for (int i = 0; i < 2; i++) { @@ -134,7 +133,7 @@ namespace osu.Game.Tests.Visual.Playlists createResults(() => userScore); waitForDisplay(); - AddStep("bind delayed handler", () => bindHandler(3000)); + AddStep("bind delayed handler", () => bindHandler(true)); for (int i = 0; i < 2; i++) { @@ -169,70 +168,60 @@ namespace osu.Game.Tests.Visual.Playlists AddWaitStep("wait for display", 5); } - private void bindHandler(double delay = 0, ScoreInfo userScore = null, bool failRequests = false) => ((DummyAPIAccess)API).HandleRequest = request => + private void bindHandler(bool delayed = false, ScoreInfo userScore = null, bool failRequests = false) => ((DummyAPIAccess)API).HandleRequest = request => { - requestComplete = false; - - if (failRequests) - { - triggerFail(request, delay); - return; - } - + // pre-check for requests we should be handling (as they are scheduled below). switch (request) { - case ShowPlaylistUserScoreRequest s: - if (userScore == null) - triggerFail(s, delay); - else - triggerSuccess(s, createUserResponse(userScore), delay); + case ShowPlaylistUserScoreRequest _: + case IndexPlaylistScoresRequest _: break; - case IndexPlaylistScoresRequest i: - triggerSuccess(i, createIndexResponse(i), delay); - break; + default: + return false; } + + requestComplete = false; + + double delay = delayed ? 3000 : 0; + + Scheduler.AddDelayed(() => + { + if (failRequests) + { + triggerFail(request); + return; + } + + switch (request) + { + case ShowPlaylistUserScoreRequest s: + if (userScore == null) + triggerFail(s); + else + triggerSuccess(s, createUserResponse(userScore)); + break; + + case IndexPlaylistScoresRequest i: + triggerSuccess(i, createIndexResponse(i)); + break; + } + }, delay); + + return true; }; - private void triggerSuccess(APIRequest req, T result, double delay) + private void triggerSuccess(APIRequest req, T result) where T : class { - if (delay == 0) - success(); - else - { - Task.Run(async () => - { - await Task.Delay(TimeSpan.FromMilliseconds(delay)); - Schedule(success); - }); - } - - void success() - { - requestComplete = true; - req.TriggerSuccess(result); - } + requestComplete = true; + req.TriggerSuccess(result); } - private void triggerFail(APIRequest req, double delay) + private void triggerFail(APIRequest req) { - if (delay == 0) - fail(); - else - { - Task.Run(async () => - { - await Task.Delay(TimeSpan.FromMilliseconds(delay)); - Schedule(fail); - }); - } - - void fail() - { - requestComplete = true; - req.TriggerFailure(new WebException("Failed.")); - } + requestComplete = true; + req.TriggerFailure(new WebException("Failed.")); } private MultiplayerScore createUserResponse([NotNull] ScoreInfo userScore) diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs index 319c2bc6fd..a08a91314b 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -11,11 +11,13 @@ using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps; using osu.Game.Users; using osuTK.Input; @@ -24,8 +26,6 @@ namespace osu.Game.Tests.Visual.Playlists { public class TestScenePlaylistsRoomSubScreen : RoomTestScene { - protected override bool UseOnlineAPI => true; - [Cached(typeof(IRoomManager))] private readonly TestRoomManager roomManager = new TestRoomManager(); @@ -38,9 +38,21 @@ namespace osu.Game.Tests.Visual.Playlists private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait(); + + ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case CreateRoomScoreRequest createRoomScoreRequest: + createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 }); + return true; + } + + return false; + }; } [SetUpSteps] @@ -59,12 +71,16 @@ namespace osu.Game.Tests.Visual.Playlists Room.Name.Value = "my awesome room"; Room.Host.Value = new User { Id = 2, Username = "peppy" }; Room.RecentParticipants.Add(Room.Host.Value); + Room.EndDate.Value = DateTimeOffset.Now.AddMinutes(5); Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, Ruleset = { Value = new OsuRuleset().RulesetInfo } }); }); + + AddStep("start match", () => match.ChildrenOfType().First().Click()); + AddUntilStep("player loader loaded", () => Stack.CurrentScreen is PlayerLoader); } [Test] diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs index 2af15923a0..f305b7255e 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs @@ -22,82 +22,17 @@ namespace osu.Game.Tests.Visual.Ranking { public class TestSceneAccuracyCircle : OsuTestScene { - [Test] - public void TestLowDRank() + [TestCase(0.2, ScoreRank.D)] + [TestCase(0.5, ScoreRank.D)] + [TestCase(0.75, ScoreRank.C)] + [TestCase(0.85, ScoreRank.B)] + [TestCase(0.925, ScoreRank.A)] + [TestCase(0.975, ScoreRank.S)] + [TestCase(0.9999, ScoreRank.S)] + [TestCase(1, ScoreRank.X)] + public void TestRank(double accuracy, ScoreRank rank) { - var score = createScore(); - score.Accuracy = 0.2; - score.Rank = ScoreRank.D; - - addCircleStep(score); - } - - [Test] - public void TestDRank() - { - var score = createScore(); - score.Accuracy = 0.5; - score.Rank = ScoreRank.D; - - addCircleStep(score); - } - - [Test] - public void TestCRank() - { - var score = createScore(); - score.Accuracy = 0.75; - score.Rank = ScoreRank.C; - - addCircleStep(score); - } - - [Test] - public void TestBRank() - { - var score = createScore(); - score.Accuracy = 0.85; - score.Rank = ScoreRank.B; - - addCircleStep(score); - } - - [Test] - public void TestARank() - { - var score = createScore(); - score.Accuracy = 0.925; - score.Rank = ScoreRank.A; - - addCircleStep(score); - } - - [Test] - public void TestSRank() - { - var score = createScore(); - score.Accuracy = 0.975; - score.Rank = ScoreRank.S; - - addCircleStep(score); - } - - [Test] - public void TestAlmostSSRank() - { - var score = createScore(); - score.Accuracy = 0.9999; - score.Rank = ScoreRank.S; - - addCircleStep(score); - } - - [Test] - public void TestSSRank() - { - var score = createScore(); - score.Accuracy = 1; - score.Rank = ScoreRank.X; + var score = createScore(accuracy, rank); addCircleStep(score); } @@ -120,7 +55,7 @@ namespace osu.Game.Tests.Visual.Ranking } } }, - new AccuracyCircle(score, true) + new AccuracyCircle(score) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -129,7 +64,7 @@ namespace osu.Game.Tests.Visual.Ranking }; }); - private ScoreInfo createScore() => new ScoreInfo + private ScoreInfo createScore(double accuracy, ScoreRank rank) => new ScoreInfo { User = new User { @@ -139,9 +74,9 @@ namespace osu.Game.Tests.Visual.Ranking Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, TotalScore = 2845370, - Accuracy = 0.95, + Accuracy = accuracy, MaxCombo = 999, - Rank = ScoreRank.S, + Rank = rank, Date = DateTimeOffset.Now, Statistics = { diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index 7be44a62de..591095252f 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Visual.Ranking Beatmap = createTestBeatmap(author) })); - AddAssert("mapper name present", () => this.ChildrenOfType().Any(spriteText => spriteText.Text == "mapper_name")); + AddAssert("mapper name present", () => this.ChildrenOfType().Any(spriteText => spriteText.Current.Value == "mapper_name")); } [Test] @@ -49,7 +49,7 @@ namespace osu.Game.Tests.Visual.Ranking })); AddAssert("mapped by text not present", () => - this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text, "mapped", "by"))); + this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text.ToString(), "mapped", "by"))); } private void showPanel(ScoreInfo score) => Child = new ExpandedPanelMiddleContentContainer(score); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index b2be7cdf88..ba6b6bd529 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -29,13 +29,8 @@ namespace osu.Game.Tests.Visual.Ranking [TestFixture] public class TestSceneResultsScreen : OsuManualInputManagerTestScene { - private BeatmapManager beatmaps; - - [BackgroundDependencyLoader] - private void load(BeatmapManager beatmaps) - { - this.beatmaps = beatmaps; - } + [Resolved] + private BeatmapManager beatmaps { get; set; } protected override void LoadComplete() { @@ -46,10 +41,6 @@ namespace osu.Game.Tests.Visual.Ranking Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); } - private TestResultsScreen createResultsScreen() => new TestResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); - - private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); - [Test] public void TestResultsWithoutPlayer() { @@ -69,12 +60,25 @@ namespace osu.Game.Tests.Visual.Ranking AddAssert("retry overlay not present", () => screen.RetryOverlay == null); } - [Test] - public void TestResultsWithPlayer() + [TestCase(0.2, ScoreRank.D)] + [TestCase(0.5, ScoreRank.D)] + [TestCase(0.75, ScoreRank.C)] + [TestCase(0.85, ScoreRank.B)] + [TestCase(0.925, ScoreRank.A)] + [TestCase(0.975, ScoreRank.S)] + [TestCase(0.9999, ScoreRank.S)] + [TestCase(1, ScoreRank.X)] + public void TestResultsWithPlayer(double accuracy, ScoreRank rank) { TestResultsScreen screen = null; - AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) + { + Accuracy = accuracy, + Rank = rank + }; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen(score))); AddUntilStep("wait for loaded", () => screen.IsLoaded); AddAssert("retry overlay present", () => screen.RetryOverlay != null); } @@ -232,6 +236,10 @@ namespace osu.Game.Tests.Visual.Ranking AddAssert("download button is enabled", () => screen.ChildrenOfType().Last().Enabled.Value); } + private TestResultsScreen createResultsScreen(ScoreInfo score = null) => new TestResultsScreen(score ?? new TestScoreInfo(new OsuRuleset().RulesetInfo)); + + private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + private class TestResultsContainer : Container { [Cached(typeof(Player))] diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs index d0067c3396..566452249f 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs @@ -1,32 +1,65 @@ // 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.Utils; using osu.Game.Beatmaps; using osu.Game.Screens.Ranking.Expanded; +using osuTK; namespace osu.Game.Tests.Visual.Ranking { public class TestSceneStarRatingDisplay : OsuTestScene { - public TestSceneStarRatingDisplay() + [Test] + public void TestDisplay() { - Child = new FillFlowContainer + AddStep("load displays", () => Child = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Children = new Drawable[] + ChildrenEnumerable = new[] { - new StarRatingDisplay(new StarDifficulty(1.23, 0)), - new StarRatingDisplay(new StarDifficulty(2.34, 0)), - new StarRatingDisplay(new StarDifficulty(3.45, 0)), - new StarRatingDisplay(new StarDifficulty(4.56, 0)), - new StarRatingDisplay(new StarDifficulty(5.67, 0)), - new StarRatingDisplay(new StarDifficulty(6.78, 0)), - new StarRatingDisplay(new StarDifficulty(10.11, 0)), - } - }; + 1.23, + 2.34, + 3.45, + 4.56, + 5.67, + 6.78, + 10.11, + }.Select(starRating => new StarRatingDisplay(new StarDifficulty(starRating, 0)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }) + }); + } + + [Test] + public void TestChangingStarRatingDisplay() + { + StarRatingDisplay starRating = null; + + AddStep("load display", () => Child = starRating = new StarRatingDisplay(new StarDifficulty(5.55, 1)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(3f), + }); + + AddRepeatStep("set random value", () => + { + starRating.Current.Value = new StarDifficulty(RNG.NextDouble(0.0, 11.0), 1); + }, 10); + + AddSliderStep("set exact stars", 0.0, 11.0, 5.55, d => + { + if (starRating != null) + starRating.Current.Value = new StarDifficulty(d, 1); + }); } } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index 8330b9b360..acf9deb3cb 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -6,6 +6,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Testing; using osu.Framework.Threading; +using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Overlays.KeyBinding; using osuTK.Input; @@ -28,6 +29,39 @@ namespace osu.Game.Tests.Visual.Settings panel.Show(); } + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Scroll to top", () => panel.ChildrenOfType().First().ScrollToTop()); + AddWaitStep("wait for scroll", 5); + } + + [Test] + public void TestBindingMouseWheelToNonGameplay() + { + scrollToAndStartBinding("Increase volume"); + AddStep("press k", () => InputManager.Key(Key.K)); + checkBinding("Increase volume", "K"); + + AddStep("click again", () => InputManager.Click(MouseButton.Left)); + AddStep("scroll mouse wheel", () => InputManager.ScrollVerticalBy(1)); + + checkBinding("Increase volume", "Wheel Up"); + } + + [Test] + public void TestBindingMouseWheelToGameplay() + { + scrollToAndStartBinding("Left button"); + AddStep("press k", () => InputManager.Key(Key.Z)); + checkBinding("Left button", "Z"); + + AddStep("click again", () => InputManager.Click(MouseButton.Left)); + AddStep("scroll mouse wheel", () => InputManager.ScrollVerticalBy(1)); + + checkBinding("Left button", "Z"); + } + [Test] public void TestClickTwiceOnClearButton() { @@ -36,6 +70,7 @@ namespace osu.Game.Tests.Visual.Settings AddStep("click first row", () => { firstRow = panel.ChildrenOfType().First(); + InputManager.MoveMouseTo(firstRow); InputManager.Click(MouseButton.Left); }); @@ -78,7 +113,7 @@ namespace osu.Game.Tests.Visual.Settings clickClearButton(); - AddAssert("first binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().First().Text.Text)); + AddAssert("first binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().First().Text.Text.ToString())); AddStep("click second binding", () => { @@ -90,7 +125,7 @@ namespace osu.Game.Tests.Visual.Settings clickClearButton(); - AddAssert("second binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().ElementAt(1).Text.Text)); + AddAssert("second binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().ElementAt(1).Text.Text.ToString())); void clickClearButton() { @@ -104,6 +139,64 @@ namespace osu.Game.Tests.Visual.Settings } } + [Test] + public void TestSingleBindingResetButton() + { + KeyBindingRow settingsKeyBindingRow = null; + + AddStep("click first row", () => + { + settingsKeyBindingRow = panel.ChildrenOfType().First(); + + InputManager.MoveMouseTo(settingsKeyBindingRow); + InputManager.Click(MouseButton.Left); + InputManager.PressKey(Key.P); + InputManager.ReleaseKey(Key.P); + }); + + AddUntilStep("restore button shown", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha > 0); + + AddStep("click reset button for bindings", () => + { + var resetButton = settingsKeyBindingRow.ChildrenOfType>().First(); + + resetButton.Click(); + }); + + AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); + + AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); + } + + [Test] + public void TestResetAllBindingsButton() + { + KeyBindingRow settingsKeyBindingRow = null; + + AddStep("click first row", () => + { + settingsKeyBindingRow = panel.ChildrenOfType().First(); + + InputManager.MoveMouseTo(settingsKeyBindingRow); + InputManager.Click(MouseButton.Left); + InputManager.PressKey(Key.P); + InputManager.ReleaseKey(Key.P); + }); + + AddUntilStep("restore button shown", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha > 0); + + AddStep("click reset button for bindings", () => + { + var resetButton = panel.ChildrenOfType().First(); + + resetButton.Click(); + }); + + AddUntilStep("restore button hidden", () => settingsKeyBindingRow.ChildrenOfType>().First().Alpha == 0); + + AddAssert("binding cleared", () => settingsKeyBindingRow.ChildrenOfType().ElementAt(0).KeyBinding.KeyCombination.Equals(settingsKeyBindingRow.Defaults.ElementAt(0))); + } + [Test] public void TestClickRowSelectsFirstBinding() { @@ -135,5 +228,37 @@ namespace osu.Game.Tests.Visual.Settings AddAssert("first binding selected", () => multiBindingRow.ChildrenOfType().First().IsBinding); } + + private void checkBinding(string name, string keyName) + { + AddAssert($"Check {name} is bound to {keyName}", () => + { + var firstRow = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text == name)); + var firstButton = firstRow.ChildrenOfType().First(); + + return firstButton.Text.Text == keyName; + }); + } + + private void scrollToAndStartBinding(string name) + { + KeyBindingRow.KeyButton firstButton = null; + + AddStep($"Scroll to {name}", () => + { + var firstRow = panel.ChildrenOfType().First(r => r.ChildrenOfType().Any(s => s.Text == name)); + firstButton = firstRow.ChildrenOfType().First(); + + panel.ChildrenOfType().First().ScrollTo(firstButton); + }); + + AddWaitStep("wait for scroll", 5); + + AddStep("click to bind", () => + { + InputManager.MoveMouseTo(firstButton); + InputManager.Click(MouseButton.Left); + }); + } } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs new file mode 100644 index 0000000000..f63145f534 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs @@ -0,0 +1,44 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Overlays.Settings; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Settings +{ + [TestFixture] + public class TestSceneSettingsItem : OsuTestScene + { + [Test] + public void TestRestoreDefaultValueButtonVisibility() + { + TestSettingsTextBox textBox = null; + + AddStep("create settings item", () => Child = textBox = new TestSettingsTextBox + { + Current = new Bindable + { + Default = "test", + Value = "test" + } + }); + AddAssert("restore button hidden", () => textBox.RestoreDefaultValueButton.Alpha == 0); + + AddStep("change value from default", () => textBox.Current.Value = "non-default"); + AddUntilStep("restore button shown", () => textBox.RestoreDefaultValueButton.Alpha > 0); + + AddStep("restore default", () => textBox.Current.SetDefault()); + AddUntilStep("restore button hidden", () => textBox.RestoreDefaultValueButton.Alpha == 0); + } + + private class TestSettingsTextBox : SettingsTextBox + { + public Drawable RestoreDefaultValueButton => this.ChildrenOfType>().Single(); + } + } +} \ No newline at end of file diff --git a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs new file mode 100644 index 0000000000..a62980addf --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs @@ -0,0 +1,75 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Platform; +using osu.Framework.Utils; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings.Sections.Input; +using osuTK; + +namespace osu.Game.Tests.Visual.Settings +{ + [TestFixture] + public class TestSceneTabletSettings : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(GameHost host) + { + var tabletHandler = new TestTabletHandler(); + + AddRange(new Drawable[] + { + new TabletSettings(tabletHandler) + { + RelativeSizeAxes = Axes.None, + Width = SettingsPanel.WIDTH, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + }); + + AddStep("Test with wide tablet", () => tabletHandler.SetTabletSize(new Vector2(160, 100))); + AddStep("Test with square tablet", () => tabletHandler.SetTabletSize(new Vector2(300, 300))); + AddStep("Test with tall tablet", () => tabletHandler.SetTabletSize(new Vector2(100, 300))); + AddStep("Test with very tall tablet", () => tabletHandler.SetTabletSize(new Vector2(100, 700))); + AddStep("Test no tablet present", () => tabletHandler.SetTabletSize(Vector2.Zero)); + } + + public class TestTabletHandler : ITabletHandler + { + public Bindable AreaOffset { get; } = new Bindable(); + public Bindable AreaSize { get; } = new Bindable(); + + public Bindable Rotation { get; } = new Bindable(); + + public IBindable Tablet => tablet; + + private readonly Bindable tablet = new Bindable(); + + public BindableBool Enabled { get; } = new BindableBool(true); + + public void SetTabletSize(Vector2 size) + { + tablet.Value = size != Vector2.Zero ? new TabletInfo($"test tablet T-{RNG.Next(999):000}", size) : null; + + AreaSize.Default = new Vector2(size.X, size.Y); + + // if it's clear the user has not configured the area, take the full area from the tablet that was just found. + if (AreaSize.Value == Vector2.Zero) + AreaSize.SetDefault(); + + AreaOffset.Default = new Vector2(size.X / 2, size.Y / 2); + + // likewise with the position, use the centre point if it has not been configured. + // it's safe to assume no user would set their centre point to 0,0 for now. + if (AreaOffset.Value == Vector2.Zero) + AreaOffset.SetDefault(); + } + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 44c9361ff8..78ddfa9ed2 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -786,9 +786,12 @@ namespace osu.Game.Tests.Visual.SongSelect } } - private void checkVisibleItemCount(bool diff, int count) => - AddAssert($"{count} {(diff ? "diffs" : "sets")} visible", () => + private void checkVisibleItemCount(bool diff, int count) + { + // until step required as we are querying against alive items, which are loaded asynchronously inside DrawableCarouselBeatmapSet. + AddUntilStep($"{count} {(diff ? "diffs" : "sets")} visible", () => carousel.Items.Count(s => (diff ? s.Item is CarouselBeatmap : s.Item is CarouselBeatmapSet) && s.Item.Visible) == count); + } private void checkNoSelection() => AddAssert("Selection is null", () => currentSelection == null); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs index acf037198f..06572f66bf 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs @@ -5,6 +5,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Beatmaps; +using osu.Game.Online.API; using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.SongSelect @@ -14,6 +15,8 @@ namespace osu.Game.Tests.Visual.SongSelect { private BeatmapDetails details; + private DummyAPIAccess api => (DummyAPIAccess)API; + [SetUp] public void Setup() => Schedule(() => { @@ -173,6 +176,8 @@ namespace osu.Game.Tests.Visual.SongSelect { OnlineBeatmapID = 162, }); + AddStep("set online", () => api.SetState(APIState.Online)); + AddStep("set offline", () => api.SetState(APIState.Offline)); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index 0b2c0ce63b..a416fd4daf 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -7,6 +7,8 @@ using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; @@ -103,27 +105,27 @@ namespace osu.Game.Tests.Visual.SongSelect private void testBeatmapLabels(Ruleset ruleset) { - AddAssert("check version", () => infoWedge.Info.VersionLabel.Text == $"{ruleset.ShortName}Version"); - AddAssert("check title", () => infoWedge.Info.TitleLabel.Text == $"{ruleset.ShortName}Source — {ruleset.ShortName}Title"); - AddAssert("check artist", () => infoWedge.Info.ArtistLabel.Text == $"{ruleset.ShortName}Artist"); - AddAssert("check author", () => infoWedge.Info.MapperContainer.Children.OfType().Any(s => s.Text == $"{ruleset.ShortName}Author")); + AddAssert("check version", () => infoWedge.Info.VersionLabel.Current.Value == $"{ruleset.ShortName}Version"); + AddAssert("check title", () => infoWedge.Info.TitleLabel.Current.Value == $"{ruleset.ShortName}Source — {ruleset.ShortName}Title"); + AddAssert("check artist", () => infoWedge.Info.ArtistLabel.Current.Value == $"{ruleset.ShortName}Artist"); + AddAssert("check author", () => infoWedge.Info.MapperContainer.ChildrenOfType().Any(s => s.Current.Value == $"{ruleset.ShortName}Author")); } private void testInfoLabels(int expectedCount) { - AddAssert("check info labels exists", () => infoWedge.Info.InfoLabelContainer.Children.Any()); - AddAssert("check info labels count", () => infoWedge.Info.InfoLabelContainer.Children.Count == expectedCount); + AddAssert("check info labels exists", () => infoWedge.Info.ChildrenOfType().Any()); + AddAssert("check info labels count", () => infoWedge.Info.ChildrenOfType().Count() == expectedCount); } [Test] public void TestNullBeatmap() { selectBeatmap(null); - AddAssert("check empty version", () => string.IsNullOrEmpty(infoWedge.Info.VersionLabel.Text)); - AddAssert("check default title", () => infoWedge.Info.TitleLabel.Text == Beatmap.Default.BeatmapInfo.Metadata.Title); - AddAssert("check default artist", () => infoWedge.Info.ArtistLabel.Text == Beatmap.Default.BeatmapInfo.Metadata.Artist); - AddAssert("check empty author", () => !infoWedge.Info.MapperContainer.Children.Any()); - AddAssert("check no info labels", () => !infoWedge.Info.InfoLabelContainer.Children.Any()); + AddAssert("check empty version", () => string.IsNullOrEmpty(infoWedge.Info.VersionLabel.Current.Value)); + AddAssert("check default title", () => infoWedge.Info.TitleLabel.Current.Value == Beatmap.Default.BeatmapInfo.Metadata.Title); + AddAssert("check default artist", () => infoWedge.Info.ArtistLabel.Current.Value == Beatmap.Default.BeatmapInfo.Metadata.Artist); + AddAssert("check empty author", () => !infoWedge.Info.MapperContainer.ChildrenOfType().Any()); + AddAssert("check no info labels", () => !infoWedge.Info.ChildrenOfType().Any()); } [Test] @@ -134,15 +136,15 @@ namespace osu.Game.Tests.Visual.SongSelect private void selectBeatmap([CanBeNull] IBeatmap b) { - BeatmapInfoWedge.BufferedWedgeInfo infoBefore = null; + Container containerBefore = null; AddStep($"select {b?.Metadata.Title ?? "null"} beatmap", () => { - infoBefore = infoWedge.Info; + containerBefore = infoWedge.DisplayedContent; infoWedge.Beatmap = Beatmap.Value = b == null ? Beatmap.Default : CreateWorkingBeatmap(b); }); - AddUntilStep("wait for async load", () => infoWedge.Info != infoBefore); + AddUntilStep("wait for async load", () => infoWedge.DisplayedContent != containerBefore); } private IBeatmap createTestBeatmap(RulesetInfo ruleset) @@ -192,7 +194,9 @@ namespace osu.Game.Tests.Visual.SongSelect private class TestBeatmapInfoWedge : BeatmapInfoWedge { - public new BufferedWedgeInfo Info => base.Info; + public new Container DisplayedContent => base.DisplayedContent; + + public new WedgeInfoText Info => base.Info; } private class TestHitObject : ConvertHitObject, IHasPosition diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs new file mode 100644 index 0000000000..271fbde5c3 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs @@ -0,0 +1,151 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; +using osuTK; + +namespace osu.Game.Tests.Visual.SongSelect +{ + public class TestSceneBeatmapMetadataDisplay : OsuTestScene + { + private BeatmapMetadataDisplay display; + + [Resolved] + private BeatmapManager manager { get; set; } + + [Cached(typeof(BeatmapDifficultyCache))] + private readonly TestBeatmapDifficultyCache testDifficultyCache = new TestBeatmapDifficultyCache(); + + [Test] + public void TestLocal([Values("Beatmap", "Some long title and stuff")] + string title, + [Values("Trial", "Some1's very hardest difficulty")] + string version) + { + showMetadataForBeatmap(() => CreateWorkingBeatmap(new Beatmap + { + BeatmapInfo = + { + Metadata = new BeatmapMetadata + { + Title = title, + }, + Version = version, + StarDifficulty = RNG.NextDouble(0, 10), + } + })); + } + + [Test] + public void TestDelayedStarRating() + { + AddStep("block calculation", () => testDifficultyCache.BlockCalculation = true); + + showMetadataForBeatmap(() => CreateWorkingBeatmap(new Beatmap + { + BeatmapInfo = + { + Metadata = new BeatmapMetadata + { + Title = "Heavy beatmap", + }, + Version = "10k objects", + StarDifficulty = 99.99f, + } + })); + + AddStep("allow calculation", () => testDifficultyCache.BlockCalculation = false); + } + + [Test] + public void TestRandomFromDatabase() + { + showMetadataForBeatmap(() => + { + var allBeatmapSets = manager.GetAllUsableBeatmapSets(IncludedDetails.Minimal); + if (allBeatmapSets.Count == 0) + return manager.DefaultBeatmap; + + var randomBeatmapSet = allBeatmapSets[RNG.Next(0, allBeatmapSets.Count - 1)]; + var randomBeatmap = randomBeatmapSet.Beatmaps[RNG.Next(0, randomBeatmapSet.Beatmaps.Count - 1)]; + + return manager.GetWorkingBeatmap(randomBeatmap); + }); + } + + private void showMetadataForBeatmap(Func getBeatmap) + { + AddStep("setup display", () => + { + var randomMods = Ruleset.Value.CreateInstance().GetAllMods().OrderBy(_ => RNG.Next()).Take(5).ToList(); + + OsuLogo logo = new OsuLogo { Scale = new Vector2(0.15f) }; + + Remove(testDifficultyCache); + + Children = new Drawable[] + { + testDifficultyCache, + display = new BeatmapMetadataDisplay(getBeatmap(), new Bindable>(randomMods), logo) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0f, + } + }; + + display.FadeIn(400, Easing.OutQuint); + }); + + AddWaitStep("wait a bit", 5); + + AddStep("finish loading", () => display.Loading = false); + } + + private class TestBeatmapDifficultyCache : BeatmapDifficultyCache + { + private TaskCompletionSource calculationBlocker; + + private bool blockCalculation; + + public bool BlockCalculation + { + get => blockCalculation; + set + { + if (value == blockCalculation) + return; + + blockCalculation = value; + + if (value) + calculationBlocker = new TaskCompletionSource(); + else + calculationBlocker?.SetResult(false); + } + } + + public override async Task GetDifficultyAsync(BeatmapInfo beatmapInfo, RulesetInfo rulesetInfo = null, IEnumerable mods = null, CancellationToken cancellationToken = default) + { + if (blockCalculation) + await calculationBlocker.Task; + + return await base.GetDifficultyAsync(beatmapInfo, rulesetInfo, mods, cancellationToken); + } + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs index 53a956c77c..5e2d5eba5d 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -32,8 +32,10 @@ namespace osu.Game.Tests.Visual.SongSelect { case GetUserRequest userRequest: userRequest.TriggerSuccess(getUser(userRequest.Ruleset.ID)); - break; + return true; } + + return false; }; }); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs index 5d0fb248df..a5b90e6655 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs @@ -36,9 +36,9 @@ namespace osu.Game.Tests.Visual.SongSelect private void load(GameHost host) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, Resources, host, Beatmap.Default)); - beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); base.Content.AddRange(new Drawable[] { diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 35c6d62cb7..102e5ee425 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -47,7 +47,7 @@ namespace osu.Game.Tests.Visual.SongSelect private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, defaultBeatmap = Beatmap.Default)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, defaultBeatmap = Beatmap.Default)); Dependencies.Cache(music = new MusicController()); @@ -207,14 +207,14 @@ namespace osu.Game.Tests.Visual.SongSelect addRulesetImportStep(0); addRulesetImportStep(0); - AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, false)); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); - AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, true)); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); AddStep("return", () => songSelect.MakeCurrent()); AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); @@ -297,13 +297,14 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); - AddStep(@"Sort by Artist", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Artist)); - AddStep(@"Sort by Title", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Title)); - AddStep(@"Sort by Author", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Author)); - AddStep(@"Sort by DateAdded", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.DateAdded)); - AddStep(@"Sort by BPM", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.BPM)); - AddStep(@"Sort by Length", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Length)); - AddStep(@"Sort by Difficulty", () => config.Set(OsuSetting.SongSelectSortingMode, SortMode.Difficulty)); + AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); + AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title)); + AddStep(@"Sort by Author", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Author)); + AddStep(@"Sort by DateAdded", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.DateAdded)); + AddStep(@"Sort by BPM", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.BPM)); + AddStep(@"Sort by Length", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Length)); + AddStep(@"Sort by Difficulty", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Difficulty)); + AddStep(@"Sort by Source", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Source)); } [Test] @@ -357,7 +358,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.Equals(target)); // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo == target); + AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo.Equals(target)); } [Test] @@ -389,7 +390,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("has correct ruleset", () => Ruleset.Value.ID == 0); // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo == target); + AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo.Equals(target)); } [Test] @@ -470,7 +471,7 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(0); // used for filter check below - AddStep("allow convert display", () => config.Set(OsuSetting.ShowConvertedBeatmaps, true)); + AddStep("allow convert display", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); @@ -648,7 +649,7 @@ namespace osu.Game.Tests.Visual.SongSelect { int changeCount = 0; - AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, false)); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, false)); AddStep("bind beatmap changed", () => { Beatmap.ValueChanged += onChange; @@ -686,7 +687,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("selection changed only fired twice", () => changeCount == 2); AddStep("unbind beatmap changed", () => Beatmap.ValueChanged -= onChange); - AddStep("change convert setting", () => config.Set(OsuSetting.ShowConvertedBeatmaps, true)); + AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); // ReSharper disable once AccessToModifiedClosure void onChange(ValueChangedEvent valueChangedEvent) => changeCount++; @@ -780,7 +781,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.ID == 3); - AddAssert("Check first item in group selected", () => Beatmap.Value.BeatmapInfo == groupIcon.Items.First().Beatmap); + AddAssert("Check first item in group selected", () => Beatmap.Value.BeatmapInfo.Equals(groupIcon.Items.First().Beatmap)); } [Test] diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs new file mode 100644 index 0000000000..0ac65b357c --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneSongSelectFooter.cs @@ -0,0 +1,35 @@ +// 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.Graphics; +using osu.Game.Screens.Select; + +namespace osu.Game.Tests.Visual.SongSelect +{ + public class TestSceneSongSelectFooter : OsuManualInputManagerTestScene + { + public TestSceneSongSelectFooter() + { + AddStep("Create footer", () => + { + Footer footer; + AddRange(new Drawable[] + { + footer = new Footer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + footer.AddButton(new FooterButtonMods(), null); + footer.AddButton(new FooterButtonRandom + { + NextRandom = () => { }, + PreviousRandom = () => { }, + }, null); + footer.AddButton(new FooterButtonOptions(), null); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs index a9747e73f9..abd1baf0ac 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs @@ -34,6 +34,7 @@ namespace osu.Game.Tests.Visual.UserInterface public void SetUp() => Schedule(() => { OsuSpriteText query; + OsuSpriteText general; OsuSpriteText ruleset; OsuSpriteText category; OsuSpriteText genre; @@ -58,6 +59,7 @@ namespace osu.Game.Tests.Visual.UserInterface Children = new Drawable[] { query = new OsuSpriteText(), + general = new OsuSpriteText(), ruleset = new OsuSpriteText(), category = new OsuSpriteText(), genre = new OsuSpriteText(), @@ -71,6 +73,7 @@ namespace osu.Game.Tests.Visual.UserInterface }; control.Query.BindValueChanged(q => query.Text = $"Query: {q.NewValue}", true); + control.General.BindCollectionChanged((u, v) => general.Text = $"General: {(control.General.Any() ? string.Join('.', control.General.Select(i => i.ToString().ToLowerInvariant())) : "")}", true); control.Ruleset.BindValueChanged(r => ruleset.Text = $"Ruleset: {r.NewValue}", true); control.Category.BindValueChanged(c => category.Text = $"Category: {c.NewValue}", true); control.Genre.BindValueChanged(g => genre.Text = $"Genre: {g.NewValue}", true); @@ -92,10 +95,10 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestExplicitConfig() { - AddStep("configure explicit content to allowed", () => localConfig.Set(OsuSetting.ShowOnlineExplicitContent, true)); + AddStep("configure explicit content to allowed", () => localConfig.SetValue(OsuSetting.ShowOnlineExplicitContent, true)); AddAssert("explicit control set to show", () => control.ExplicitContent.Value == SearchExplicit.Show); - AddStep("configure explicit content to disallowed", () => localConfig.Set(OsuSetting.ShowOnlineExplicitContent, false)); + AddStep("configure explicit content to disallowed", () => localConfig.SetValue(OsuSetting.ShowOnlineExplicitContent, false)); AddAssert("explicit control set to hide", () => control.ExplicitContent.Value == SearchExplicit.Hide); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs new file mode 100644 index 0000000000..90c3e142df --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControlHeader.cs @@ -0,0 +1,86 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneBreadcrumbControlHeader : OsuTestScene + { + private static readonly string[] items = { "first", "second", "third", "fourth", "fifth" }; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Red); + + private TestHeader header; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = header = new TestHeader + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + + [Test] + public void TestAddAndRemoveItem() + { + foreach (var item in items.Skip(1)) + AddStep($"Add {item} item", () => header.AddItem(item)); + + foreach (var item in items.Reverse().SkipLast(3)) + AddStep($"Remove {item} item", () => header.RemoveItem(item)); + + AddStep("Clear items", () => header.ClearItems()); + + foreach (var item in items) + AddStep($"Add {item} item", () => header.AddItem(item)); + + foreach (var item in items) + AddStep($"Remove {item} item", () => header.RemoveItem(item)); + } + + private class TestHeader : BreadcrumbControlOverlayHeader + { + public TestHeader() + { + TabControl.AddItem(items[0]); + Current.Value = items[0]; + } + + public void AddItem(string value) + { + TabControl.AddItem(value); + Current.Value = TabControl.Items.LastOrDefault(); + } + + public void RemoveItem(string value) + { + TabControl.RemoveItem(value); + Current.Value = TabControl.Items.LastOrDefault(); + } + + public void ClearItems() + { + TabControl.Clear(); + Current.Value = null; + } + + protected override OverlayTitle CreateTitle() => new TestTitle(); + } + + private class TestTitle : OverlayTitle + { + public TestTitle() + { + Title = "Test Title"; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 81862448a8..3f9e0048dd 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -81,10 +81,10 @@ namespace osu.Game.Tests.Visual.UserInterface var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); dependencies.Cache(rulesetStore = new RulesetStore(ContextFactory)); - dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesetStore, null, dependencies.Get(), dependencies.Get(), Beatmap.Default)); + dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesetStore, null, dependencies.Get(), Resources, dependencies.Get(), Beatmap.Default)); dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory)); - beatmap = beatmapManager.Import(new ImportTask(TestResources.GetTestBeatmapForImport())).Result.Beatmaps[0]; + beatmap = beatmapManager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).Result.Beatmaps[0]; for (int i = 0; i < 50; i++) { @@ -145,7 +145,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("click delete option", () => { - InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => i.Item.Text.Value.ToLowerInvariant() == "delete")); + InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => i.Item.Text.Value.ToString().ToLowerInvariant() == "delete")); InputManager.Click(MouseButton.Left); }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs index cc4a57fb83..f5cba2c900 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; @@ -11,13 +13,20 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneDialogOverlay : OsuTestScene { - public TestSceneDialogOverlay() + private DialogOverlay overlay; + + [SetUpSteps] + public void SetUpSteps() { - DialogOverlay overlay; + AddStep("create dialog overlay", () => Child = overlay = new DialogOverlay()); + } - Add(overlay = new DialogOverlay()); + [Test] + public void TestBasic() + { + TestPopupDialog dialog = null; - AddStep("dialog #1", () => overlay.Push(new TestPopupDialog + AddStep("dialog #1", () => overlay.Push(dialog = new TestPopupDialog { Icon = FontAwesome.Regular.TrashAlt, HeaderText = @"Confirm deletion of", @@ -37,7 +46,9 @@ namespace osu.Game.Tests.Visual.UserInterface }, })); - AddStep("dialog #2", () => overlay.Push(new TestPopupDialog + AddAssert("first dialog displayed", () => overlay.CurrentDialog == dialog); + + AddStep("dialog #2", () => overlay.Push(dialog = new TestPopupDialog { Icon = FontAwesome.Solid.Cog, HeaderText = @"What do you want to do with", @@ -70,6 +81,42 @@ namespace osu.Game.Tests.Visual.UserInterface }, }, })); + + AddAssert("second dialog displayed", () => overlay.CurrentDialog == dialog); + } + + [Test] + public void TestDismissBeforePush() + { + AddStep("dismissed dialog push", () => + { + overlay.Push(new TestPopupDialog + { + State = { Value = Visibility.Hidden } + }); + }); + + AddAssert("no dialog pushed", () => overlay.CurrentDialog == null); + } + + [Test] + public void TestDismissBeforePushViaButtonPress() + { + AddStep("dismissed dialog push", () => + { + TestPopupDialog dialog; + overlay.Push(dialog = new TestPopupDialog + { + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton { Text = @"OK" }, + }, + }); + + dialog.PerformOkAction(); + }); + + AddAssert("no dialog pushed", () => overlay.CurrentDialog == null); } private class TestPopupDialog : PopupDialog diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs index 1e3b1c2ffd..546e905ded 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs @@ -76,7 +76,7 @@ namespace osu.Game.Tests.Visual.UserInterface var multiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); var expectedValue = multiplier.Equals(1.0) ? string.Empty : $"{multiplier:N2}x"; - return expectedValue == footerButtonMods.MultiplierText.Text; + return expectedValue == footerButtonMods.MultiplierText.Current.Value; } private class TestFooterButtonMods : FooterButtonMods diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs new file mode 100644 index 0000000000..826da17ca8 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs @@ -0,0 +1,70 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneLabelledColourPalette : OsuTestScene + { + private LabelledColourPalette component; + + [Test] + public void TestPalette([Values] bool hasDescription) + { + createColourPalette(hasDescription); + + AddRepeatStep("add random colour", () => component.Colours.Add(randomColour()), 4); + + AddStep("set custom prefix", () => component.ColourNamePrefix = "Combo"); + + AddRepeatStep("remove random colour", () => + { + if (component.Colours.Count > 0) + component.Colours.RemoveAt(RNG.Next(component.Colours.Count)); + }, 8); + } + + private void createColourPalette(bool hasDescription = false) + { + AddStep("create component", () => + { + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + AutoSizeAxes = Axes.Y, + Child = component = new LabelledColourPalette + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ColourNamePrefix = "My colour #" + } + }; + + component.Label = "a sample component"; + component.Description = hasDescription ? "this text describes the component" : string.Empty; + + component.Colours.AddRange(new[] + { + Color4.DarkRed, + Color4.Aquamarine, + Color4.Goldenrod, + Color4.Gainsboro + }); + }); + } + + private Color4 randomColour() => new Color4( + RNG.NextSingle(), + RNG.NextSingle(), + RNG.NextSingle(), + 1); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs index 5582cc6826..b46d35a84d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs @@ -264,7 +264,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void moveLogoFacade() { - if (!(logoFacade?.Transforms).Any() && !(transferContainer?.Transforms).Any()) + if (!logoFacade.Transforms.Any() && !transferContainer.Transforms.Any()) { Random random = new Random(); trackingContainer.Delay(500).MoveTo(new Vector2(random.Next(0, (int)logo.Parent.DrawWidth), random.Next(0, (int)logo.Parent.DrawHeight)), 300); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs index 443cf59003..fdc21d80ff 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs @@ -57,6 +57,8 @@ namespace osu.Game.Tests.Visual.UserInterface private abstract class TestMod : Mod, IApplicableMod { public override double ScoreMultiplier => 1.0; + + public override string Description => "This is a test mod."; } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs index 89f9b7381b..bda1973354 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -167,7 +167,7 @@ namespace osu.Game.Tests.Visual.UserInterface GetModButton(mod).SelectNext(1); public void SetModSettingsWidth(float newWidth) => - ModSettingsContainer.Width = newWidth; + ModSettingsContainer.Parent.Width = newWidth; } public class TestRulesetInfo : RulesetInfo @@ -226,6 +226,8 @@ namespace osu.Game.Tests.Visual.UserInterface { public override double ScoreMultiplier => 1.0; + public override string Description => "This is a customisable test mod."; + public override ModType Type => ModType.Conversion; [SettingSource("Sample float", "Change something for a mod")] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs index 45720548c8..493e2f54e5 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs @@ -44,22 +44,22 @@ namespace osu.Game.Tests.Visual.UserInterface protected override void InitialiseDefaults() { - Set(TestConfigSetting.ToggleSettingNoKeybind, false); - Set(TestConfigSetting.EnumSettingNoKeybind, EnumSetting.Setting1); - Set(TestConfigSetting.ToggleSettingWithKeybind, false); - Set(TestConfigSetting.EnumSettingWithKeybind, EnumSetting.Setting1); + SetDefault(TestConfigSetting.ToggleSettingNoKeybind, false); + SetDefault(TestConfigSetting.EnumSettingNoKeybind, EnumSetting.Setting1); + SetDefault(TestConfigSetting.ToggleSettingWithKeybind, false); + SetDefault(TestConfigSetting.EnumSettingWithKeybind, EnumSetting.Setting1); base.InitialiseDefaults(); } - public void ToggleSetting(TestConfigSetting setting) => Set(setting, !Get(setting)); + public void ToggleSetting(TestConfigSetting setting) => SetValue(setting, !Get(setting)); public void IncrementEnumSetting(TestConfigSetting setting) { var nextValue = Get(setting) + 1; if (nextValue > EnumSetting.Setting4) nextValue = EnumSetting.Setting1; - Set(setting, nextValue); + SetValue(setting, nextValue); } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs new file mode 100644 index 0000000000..931af7bc95 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs @@ -0,0 +1,249 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers.Markdown; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuMarkdownContainer : OsuTestScene + { + private OsuMarkdownContainer markdownContainer; + + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Orange); + + [SetUp] + public void Setup() => Schedule(() => + { + Children = new Drawable[] + { + new Box + { + Colour = overlayColour.Background5, + RelativeSizeAxes = Axes.Both, + }, + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20), + Child = markdownContainer = new OsuMarkdownContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + }; + }); + + [Test] + public void TestEmphases() + { + AddStep("Emphases", () => + { + markdownContainer.Text = @"_italic with underscore_ +*italic with asterisk* +__bold with underscore__ +**bold with asterisk** +*__italic with asterisk, bold with underscore__* +_**italic with underscore, bold with asterisk**_"; + }); + } + + [Test] + public void TestHeading() + { + AddStep("Add Heading", () => + { + markdownContainer.Text = @"# Header 1 +## Header 2 +### Header 3 +#### Header 4 +##### Header 5"; + }); + } + + [Test] + public void TestLink() + { + AddStep("Add Link", () => + { + markdownContainer.Text = "[Welcome to osu!](https://osu.ppy.sh)"; + }); + } + + [Test] + public void TestLinkWithInlineText() + { + AddStep("Add Link with inline text", () => + { + markdownContainer.Text = "Hey, [welcome to osu!](https://osu.ppy.sh) Please enjoy the game."; + }); + } + + [Test] + public void TestLinkWithTitle() + { + AddStep("Add Link with title", () => + { + markdownContainer.Text = "[wikipedia](https://www.wikipedia.org \"The Free Encyclopedia\")"; + }); + } + + [Test] + public void TestInlineCode() + { + AddStep("Add inline code", () => + { + markdownContainer.Text = "This is `inline code` text"; + }); + } + + [Test] + public void TestParagraph() + { + AddStep("Add paragraph", () => + { + markdownContainer.Text = @"first paragraph + +second paragraph + +third paragraph"; + }); + } + + [Test] + public void TestFencedCodeBlock() + { + AddStep("Add Code Block", () => + { + markdownContainer.Text = @"```markdown +# Markdown code block + +This is markdown code block. +```"; + }); + } + + [Test] + public void TestSeparator() + { + AddStep("Add Seperator", () => + { + markdownContainer.Text = @"Line above + +--- + +Line below"; + }); + } + + [Test] + public void TestQuote() + { + AddStep("Add quote", () => + { + markdownContainer.Text = + @"> Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."; + }); + } + + [Test] + public void TestTable() + { + AddStep("Add Table", () => + { + markdownContainer.Text = + @"| Left Aligned | Center Aligned | Right Aligned | +| :------------------- | :--------------------: | ---------------------:| +| Long Align Left Text | Long Align Center Text | Long Align Right Text | +| Align Left | Align Center | Align Right | +| Left | Center | Right |"; + }); + } + + [Test] + public void TestUnorderedList() + { + AddStep("Add Unordered List", () => + { + markdownContainer.Text = @"- First item level 1 +- Second item level 1 + - First item level 2 + - First item level 3 + - Second item level 3 + - Third item level 3 + - First item level 4 + - Second item level 4 + - Third item level 4 + - Second item level 2 + - Third item level 2 +- Third item level 1"; + }); + } + + [Test] + public void TestOrderedList() + { + AddStep("Add Ordered List", () => + { + markdownContainer.Text = @"1. First item level 1 +2. Second item level 1 + 1. First item level 2 + 1. First item level 3 + 2. Second item level 3 + 3. Third item level 3 + 1. First item level 4 + 2. Second item level 4 + 3. Third item level 4 + 2. Second item level 2 + 3. Third item level 2 +3. Third item level 1"; + }); + } + + [Test] + public void TestLongMixedList() + { + AddStep("Add long mixed list", () => + { + markdownContainer.Text = @"1. The osu! World Cup is a country-based team tournament played on the osu! game mode. + - While this competition is planned as a 4 versus 4 setup, this may change depending on the number of incoming registrations. +2. Beatmap scoring is based on Score V2. +3. The beatmaps for each round will be announced by the map selectors in advance on the Sunday before the actual matches take place. Only these beatmaps will be used during the respective matches. + - One beatmap will be a tiebreaker beatmap. This beatmap will only be played in case of a tie. **The only exception to this is the Qualifiers pool.** +4. The match schedule will be settled by the Tournament Management (see the [scheduling instructions](#scheduling-instructions)). +5. If no staff or referee is available, the match will be postponed. +6. Use of the Visual Settings to alter background dim or disable beatmap elements like storyboards and skins are allowed. +7. If the beatmap ends in a draw, the map will be nullified and replayed. +8. If a player disconnects, their scores will not be counted towards their team's total. + - Disconnects within 30 seconds or 25% of the beatmap length (whichever happens first) after beatmap begin can be aborted and/or rematched. This is up to the referee's discretion. +9. Beatmaps cannot be reused in the same match unless the map was nullified. +10. If less than the minimum required players attend, the maximum time the match can be postponed is 10 minutes. +11. Exchanging players during a match is allowed without limitations. + - **If a map rematch is required, exchanging players is not allowed. With the referee's discretion, an exception can be made if the previous roster is unavailable to play.** +12. Lag is not a valid reason to nullify a beatmap. +13. All players are supposed to keep the match running fluently and without delays. Penalties can be issued to the players if they cause excessive match delays. +14. If a player disconnects between maps and the team cannot provide a replacement, the match can be delayed 10 minutes at maximum. +15. All players and referees must be treated with respect. Instructions of the referees and tournament Management are to be followed. Decisions labeled as final are not to be objected. +16. Disrupting the match by foul play, insulting and provoking other players or referees, delaying the match or other deliberate inappropriate misbehavior is strictly prohibited. +17. The multiplayer chatrooms are subject to the [osu! community rules](/wiki/Rules). + - Breaking the chat rules will result in a silence. Silenced players can not participate in multiplayer matches and must be exchanged for the time being. +18. **The seeding method will be revealed after all the teams have played their Qualifier rounds.** +19. Unexpected incidents are handled by the tournament management. Referees may allow higher tolerance depending on the circumstances. This is up to their discretion. +20. Penalties for violating the tournament rules may include: + - Exclusion of specific players for one beatmap + - Exclusion of specific players for an entire match + - Declaring the match as Lost by Default + - Disqualification from the entire tournament + - Disqualification from the current and future official tournaments until appealed + - Any modification of these rules will be announced."; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs index 29aeb6a4b2..18ec631f37 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs @@ -14,7 +14,7 @@ namespace osu.Game.Tests.Visual.UserInterface public class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene { [Test] - public void TestTernaryMenuItem() + public void TestTernaryRadioMenuItem() { OsuMenu menu = null; @@ -30,9 +30,57 @@ namespace osu.Game.Tests.Visual.UserInterface Origin = Anchor.Centre, Items = new[] { - new TernaryStateMenuItem("First"), - new TernaryStateMenuItem("Second") { State = { BindTarget = state } }, - new TernaryStateMenuItem("Third") { State = { Value = TernaryState.True } }, + new TernaryStateRadioMenuItem("First"), + new TernaryStateRadioMenuItem("Second") { State = { BindTarget = state } }, + new TernaryStateRadioMenuItem("Third") { State = { Value = TernaryState.True } }, + } + }; + }); + + checkState(TernaryState.Indeterminate); + + click(); + checkState(TernaryState.True); + + click(); + checkState(TernaryState.True); + + click(); + checkState(TernaryState.True); + + AddStep("change state via bindable", () => state.Value = TernaryState.True); + + void click() => + AddStep("click", () => + { + InputManager.MoveMouseTo(menu.ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Left); + }); + + void checkState(TernaryState expected) + => AddAssert($"state is {expected}", () => state.Value == expected); + } + + [Test] + public void TestTernaryToggleMenuItem() + { + OsuMenu menu = null; + + Bindable state = new Bindable(TernaryState.Indeterminate); + + AddStep("create menu", () => + { + state.Value = TernaryState.Indeterminate; + + Child = menu = new OsuMenu(Direction.Vertical, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Items = new[] + { + new TernaryStateToggleMenuItem("First"), + new TernaryStateToggleMenuItem("Second") { State = { BindTarget = state } }, + new TernaryStateToggleMenuItem("Third") { State = { Value = TernaryState.True } }, } }; }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs new file mode 100644 index 0000000000..4fef93e291 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs @@ -0,0 +1,183 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneUpdateableBeatmapSetCover : OsuTestScene + { + [Test] + public void TestLocal([Values] BeatmapSetCoverType coverType) + { + AddStep("setup cover", () => Child = new UpdateableBeatmapSetCover(coverType) + { + BeatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet, + RelativeSizeAxes = Axes.Both, + Masking = true, + }); + + AddUntilStep("wait for load", () => this.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); + } + + [Test] + public void TestUnloadAndReload() + { + OsuScrollContainer scroll = null; + List covers = new List(); + + AddStep("setup covers", () => + { + BeatmapSetInfo setInfo = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet; + + FillFlowContainer fillFlow; + + Child = scroll = new OsuScrollContainer + { + Size = new Vector2(500f), + Child = fillFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Padding = new MarginPadding { Bottom = 550 } + } + }; + + var coverTypes = Enum.GetValues(typeof(BeatmapSetCoverType)) + .Cast() + .ToList(); + + for (int i = 0; i < 25; i++) + { + var coverType = coverTypes[i % coverTypes.Count]; + + var cover = new UpdateableBeatmapSetCover(coverType) + { + BeatmapSet = setInfo, + Height = 100, + Masking = true, + }; + + if (coverType == BeatmapSetCoverType.Cover) + cover.Width = 500; + else if (coverType == BeatmapSetCoverType.Card) + cover.Width = 400; + else if (coverType == BeatmapSetCoverType.List) + cover.Size = new Vector2(100, 50); + + fillFlow.Add(cover); + covers.Add(cover); + } + }); + + var loadedCovers = covers.Where(c => c.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); + + AddUntilStep("some loaded", () => loadedCovers.Any()); + AddStep("scroll to end", () => scroll.ScrollToEnd()); + AddUntilStep("all unloaded", () => !loadedCovers.Any()); + } + + [Test] + public void TestSetNullBeatmapWhileLoading() + { + TestUpdateableBeatmapSetCover updateableCover = null; + + AddStep("setup cover", () => Child = updateableCover = new TestUpdateableBeatmapSetCover + { + BeatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet, + RelativeSizeAxes = Axes.Both, + Masking = true, + }); + + AddStep("change model", () => updateableCover.BeatmapSet = null); + AddWaitStep("wait some", 5); + AddAssert("no cover added", () => !updateableCover.ChildrenOfType().Any()); + } + + [Test] + public void TestCoverChangeOnNewBeatmap() + { + TestUpdateableBeatmapSetCover updateableCover = null; + BeatmapSetCover initialCover = null; + + AddStep("setup cover", () => Child = updateableCover = new TestUpdateableBeatmapSetCover(0) + { + BeatmapSet = createBeatmapWithCover("https://assets.ppy.sh/beatmaps/1189904/covers/cover.jpg"), + RelativeSizeAxes = Axes.Both, + Masking = true, + Alpha = 0.4f + }); + + AddUntilStep("cover loaded", () => updateableCover.ChildrenOfType().Any()); + AddStep("store initial cover", () => initialCover = updateableCover.ChildrenOfType().Single()); + AddUntilStep("wait for fade complete", () => initialCover.Alpha == 1); + + AddStep("switch beatmap", + () => updateableCover.BeatmapSet = createBeatmapWithCover("https://assets.ppy.sh/beatmaps/1079428/covers/cover.jpg")); + AddUntilStep("new cover loaded", () => updateableCover.ChildrenOfType().Except(new[] { initialCover }).Any()); + } + + private static BeatmapSetInfo createBeatmapWithCover(string coverUrl) => new BeatmapSetInfo + { + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers { Cover = coverUrl } + } + }; + + private class TestUpdateableBeatmapSetCover : UpdateableBeatmapSetCover + { + private readonly int loadDelay; + + public TestUpdateableBeatmapSetCover(int loadDelay = 10000) + { + this.loadDelay = loadDelay; + } + + protected override Drawable CreateDrawable(BeatmapSetInfo model) + { + if (model == null) + return null; + + return new TestBeatmapSetCover(model, loadDelay) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + }; + } + } + + private class TestBeatmapSetCover : BeatmapSetCover + { + private readonly int loadDelay; + + public TestBeatmapSetCover(BeatmapSetInfo set, int loadDelay) + : base(set) + { + this.loadDelay = loadDelay; + } + + [BackgroundDependencyLoader] + private void load() + { + Thread.Sleep(loadDelay); + } + } + } +} diff --git a/osu.Game.Tests/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs index 8c8c827404..5477e4a0f8 100644 --- a/osu.Game.Tests/WaveformTestBeatmap.cs +++ b/osu.Game.Tests/WaveformTestBeatmap.cs @@ -11,6 +11,7 @@ using osu.Game.Beatmaps; using osu.Game.IO.Archives; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Skinning; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; @@ -52,6 +53,10 @@ namespace osu.Game.Tests protected override Waveform GetWaveform() => new Waveform(trackStore.GetStream(firstAudioFile)); + protected override ISkin GetSkin() => null; + + public override Stream GetStream(string storagePath) => null; + protected override Track GetBeatmapTrack() => trackStore.Get(firstAudioFile); private string firstAudioFile diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 7e3868bd3b..35d3c7f202 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -3,20 +3,23 @@ - - + + - + WinExe net5.0 + + tests.ruleset + - \ No newline at end of file + diff --git a/osu.Game.Tests/tests.ruleset b/osu.Game.Tests/tests.ruleset new file mode 100644 index 0000000000..a0abb781d3 --- /dev/null +++ b/osu.Game.Tests/tests.ruleset @@ -0,0 +1,6 @@ + + + + + + diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs index 567d9f0d62..46c3b8bc3b 100644 --- a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -50,7 +50,7 @@ namespace osu.Game.Tournament.Tests.NonVisual storage.DeleteDirectory(string.Empty); using (var storageConfig = new TournamentStorageManager(storage)) - storageConfig.Set(StorageConfig.CurrentTournament, custom_tournament); + storageConfig.SetValue(StorageConfig.CurrentTournament, custom_tournament); try { diff --git a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs new file mode 100644 index 0000000000..4c5f5a7a1a --- /dev/null +++ b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Platform; +using osu.Game.Tournament.IO; +using osu.Game.Tournament.IPC; + +namespace osu.Game.Tournament.Tests.NonVisual +{ + [TestFixture] + public class IPCLocationTest + { + [Test] + public void CheckIPCLocation() + { + // don't use clean run because files are being written before osu! launches. + using (HeadlessGameHost host = new HeadlessGameHost(nameof(CheckIPCLocation))) + { + string basePath = Path.Combine(RuntimeInfo.StartupDirectory, "headless", nameof(CheckIPCLocation)); + + // Set up a fake IPC client for the IPC Storage to switch to. + string testStableInstallDirectory = Path.Combine(basePath, "stable-ce"); + Directory.CreateDirectory(testStableInstallDirectory); + + string ipcFile = Path.Combine(testStableInstallDirectory, "ipc.txt"); + File.WriteAllText(ipcFile, string.Empty); + + try + { + var osu = loadOsu(host); + TournamentStorage storage = (TournamentStorage)osu.Dependencies.Get(); + FileBasedIPC ipc = null; + + waitForOrAssert(() => (ipc = osu.Dependencies.Get() as FileBasedIPC) != null, @"ipc could not be populated in a reasonable amount of time"); + + Assert.True(ipc.SetIPCLocation(testStableInstallDirectory)); + Assert.True(storage.AllTournaments.Exists("stable.json")); + } + finally + { + host.Storage.DeleteDirectory(testStableInstallDirectory); + host.Storage.DeleteDirectory("tournaments"); + host.Exit(); + } + } + } + + private TournamentGameBase loadOsu(GameHost host) + { + var osu = new TournamentGameBase(); + Task.Run(() => host.Run(osu)); + waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); + return osu; + } + + private static void waitForOrAssert(Func result, string failureMessage, int timeout = 90000) + { + Task task = Task.Run(() => + { + while (!result()) Thread.Sleep(200); + }); + + Assert.IsTrue(task.Wait(timeout), failureMessage); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs new file mode 100644 index 0000000000..e2954c8f10 --- /dev/null +++ b/osu.Game.Tournament.Tests/Screens/TestSceneDrawingsScreen.cs @@ -0,0 +1,35 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Game.Graphics.Cursor; +using osu.Game.Tournament.Screens.Drawings; + +namespace osu.Game.Tournament.Tests.Screens +{ + public class TestSceneDrawingsScreen : TournamentTestScene + { + [BackgroundDependencyLoader] + private void load(Storage storage) + { + using (var stream = storage.GetStream("drawings.txt", FileAccess.Write)) + using (var writer = new StreamWriter(stream)) + { + writer.WriteLine("KR : South Korea : KOR"); + writer.WriteLine("US : United States : USA"); + writer.WriteLine("PH : Philippines : PHL"); + writer.WriteLine("BR : Brazil : BRA"); + writer.WriteLine("JP : Japan : JPN"); + } + + Add(new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new DrawingsScreen() + }); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs index c1159dc000..522567584d 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs @@ -1,9 +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.Linq; +using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Testing; using osu.Game.Tournament.Components; using osu.Game.Tournament.Screens.Gameplay; +using osu.Game.Tournament.Screens.Gameplay.Components; namespace osu.Game.Tournament.Tests.Screens { @@ -18,5 +22,24 @@ namespace osu.Game.Tournament.Tests.Screens Add(new GameplayScreen()); Add(chat); } + + [Test] + public void TestWarmup() + { + checkScoreVisibility(false); + + toggleWarmup(); + checkScoreVisibility(true); + + toggleWarmup(); + checkScoreVisibility(false); + } + + private void checkScoreVisibility(bool visible) + => AddUntilStep($"scores {(visible ? "shown" : "hidden")}", + () => this.ChildrenOfType().All(score => score.Alpha == (visible ? 1 : 0))); + + private void toggleWarmup() + => AddStep("toggle warmup", () => this.ChildrenOfType().First().Click()); } } diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs index b3f78c92d9..e89aac73fa 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs @@ -17,11 +17,12 @@ namespace osu.Game.Tournament.Tests.Screens [BackgroundDependencyLoader] private void load() { - var match = new TournamentMatch(); - match.Team1.Value = Ladder.Teams.FirstOrDefault(t => t.Acronym.Value == "USA"); - match.Team2.Value = Ladder.Teams.FirstOrDefault(t => t.Acronym.Value == "JPN"); - match.Round.Value = Ladder.Rounds.FirstOrDefault(g => g.Name.Value == "Finals"); - ladder.CurrentMatch.Value = match; + ladder.CurrentMatch.Value = new TournamentMatch + { + Team1 = { Value = Ladder.Teams.FirstOrDefault(t => t.Acronym.Value == "USA") }, + Team2 = { Value = Ladder.Teams.FirstOrDefault(t => t.Acronym.Value == "JPN") }, + Round = { Value = Ladder.Rounds.FirstOrDefault(g => g.Name.Value == "Finals") } + }; Add(new TeamIntroScreen { diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs index cdfd19c157..1fa0ffc8e9 100644 --- a/osu.Game.Tournament.Tests/TournamentTestScene.cs +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -10,6 +10,7 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Tests.Visual; +using osu.Game.Tournament.IO; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; using osu.Game.Users; @@ -28,7 +29,7 @@ namespace osu.Game.Tournament.Tests protected MatchIPCInfo IPCInfo { get; private set; } = new MatchIPCInfo(); [BackgroundDependencyLoader] - private void load(Storage storage) + private void load(TournamentStorage storage) { Ladder.Ruleset.Value ??= rulesetStore.AvailableRulesets.First(); diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 77ae06d89c..2084be765a 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index d1197b1a61..e6d73c6e83 100644 --- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs +++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs @@ -74,9 +74,9 @@ namespace osu.Game.Tournament.Components { new TournamentSpriteText { - Text = new LocalisedString(( + Text = new RomanisableString( $"{Beatmap.Metadata.ArtistUnicode ?? Beatmap.Metadata.Artist} - {Beatmap.Metadata.TitleUnicode ?? Beatmap.Metadata.Title}", - $"{Beatmap.Metadata.Artist} - {Beatmap.Metadata.Title}")), + $"{Beatmap.Metadata.Artist} - {Beatmap.Metadata.Title}"), Font = OsuFont.Torus.With(weight: FontWeight.Bold), }, new FillFlowContainer diff --git a/osu.Game.Tournament/IO/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentStorage.cs index 2ba1b6be8f..044b60bbd5 100644 --- a/osu.Game.Tournament/IO/TournamentStorage.cs +++ b/osu.Game.Tournament/IO/TournamentStorage.cs @@ -1,12 +1,12 @@ // 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 osu.Framework.Bindables; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.IO; -using System.IO; -using System.Collections.Generic; using osu.Game.Tournament.Configuration; namespace osu.Game.Tournament.IO @@ -15,7 +15,12 @@ namespace osu.Game.Tournament.IO { private const string default_tournament = "default"; private readonly Storage storage; - private readonly Storage allTournaments; + + /// + /// The storage where all tournaments are located. + /// + public readonly Storage AllTournaments; + private readonly TournamentStorageManager storageConfig; public readonly Bindable CurrentTournament; @@ -23,16 +28,16 @@ namespace osu.Game.Tournament.IO : base(storage.GetStorageForDirectory("tournaments"), string.Empty) { this.storage = storage; - allTournaments = UnderlyingStorage; + AllTournaments = UnderlyingStorage; storageConfig = new TournamentStorageManager(storage); if (storage.Exists("tournament.ini")) { - ChangeTargetStorage(allTournaments.GetStorageForDirectory(storageConfig.Get(StorageConfig.CurrentTournament))); + ChangeTargetStorage(AllTournaments.GetStorageForDirectory(storageConfig.Get(StorageConfig.CurrentTournament))); } else - Migrate(allTournaments.GetStorageForDirectory(default_tournament)); + Migrate(AllTournaments.GetStorageForDirectory(default_tournament)); CurrentTournament = storageConfig.GetBindable(StorageConfig.CurrentTournament); Logger.Log("Using tournament storage: " + GetFullPath(string.Empty)); @@ -42,11 +47,11 @@ namespace osu.Game.Tournament.IO private void updateTournament(ValueChangedEvent newTournament) { - ChangeTargetStorage(allTournaments.GetStorageForDirectory(newTournament.NewValue)); + ChangeTargetStorage(AllTournaments.GetStorageForDirectory(newTournament.NewValue)); Logger.Log("Changing tournament storage: " + GetFullPath(string.Empty)); } - public IEnumerable ListTournaments() => allTournaments.GetDirectories(string.Empty); + public IEnumerable ListTournaments() => AllTournaments.GetDirectories(string.Empty); public override void Migrate(Storage newStorage) { @@ -70,7 +75,7 @@ namespace osu.Game.Tournament.IO moveFileIfExists("drawings.ini", destination); ChangeTargetStorage(newStorage); - storageConfig.Set(StorageConfig.CurrentTournament, default_tournament); + storageConfig.SetValue(StorageConfig.CurrentTournament, default_tournament); storageConfig.Save(); } diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs index 71417d1cc6..f538d4a7d9 100644 --- a/osu.Game.Tournament/IPC/FileBasedIPC.cs +++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs @@ -7,6 +7,7 @@ using System.Linq; using JetBrains.Annotations; using Microsoft.Win32; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Threading; @@ -77,8 +78,8 @@ namespace osu.Game.Tournament.IPC using (var stream = IPCStorage.GetStream(file_ipc_filename)) using (var sr = new StreamReader(stream)) { - var beatmapId = int.Parse(sr.ReadLine()); - var mods = int.Parse(sr.ReadLine()); + var beatmapId = int.Parse(sr.ReadLine().AsNonNull()); + var mods = int.Parse(sr.ReadLine().AsNonNull()); if (lastBeatmapId != beatmapId) { @@ -124,7 +125,7 @@ namespace osu.Game.Tournament.IPC using (var stream = IPCStorage.GetStream(file_ipc_state_filename)) using (var sr = new StreamReader(stream)) { - State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine()); + State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine().AsNonNull()); } } catch (Exception) diff --git a/osu.Game.Tournament/Models/StableInfo.cs b/osu.Game.Tournament/Models/StableInfo.cs index 0b0050a245..1ebc81c773 100644 --- a/osu.Game.Tournament/Models/StableInfo.cs +++ b/osu.Game.Tournament/Models/StableInfo.cs @@ -5,6 +5,7 @@ using System; using System.IO; using Newtonsoft.Json; using osu.Framework.Platform; +using osu.Game.Tournament.IO; namespace osu.Game.Tournament.Models { @@ -24,18 +25,18 @@ namespace osu.Game.Tournament.Models ///
public event Action OnStableInfoSaved; - private const string config_path = "tournament/stable.json"; + private const string config_path = "stable.json"; - private readonly Storage storage; + private readonly Storage configStorage; - public StableInfo(Storage storage) + public StableInfo(TournamentStorage storage) { - this.storage = storage; + configStorage = storage.AllTournaments; - if (!storage.Exists(config_path)) + if (!configStorage.Exists(config_path)) return; - using (Stream stream = storage.GetStream(config_path, FileAccess.Read, FileMode.Open)) + using (Stream stream = configStorage.GetStream(config_path, FileAccess.Read, FileMode.Open)) using (var sr = new StreamReader(stream)) { JsonConvert.PopulateObject(sr.ReadToEnd(), this); @@ -44,7 +45,7 @@ namespace osu.Game.Tournament.Models public void SaveChanges() { - using (var stream = storage.GetStream(config_path, FileAccess.Write, FileMode.Create)) + using (var stream = configStorage.GetStream(config_path, FileAccess.Write, FileMode.Create)) using (var sw = new StreamWriter(stream)) { sw.Write(JsonConvert.SerializeObject(this, diff --git a/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs b/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs index d197c0f5d9..1a2f5a1ff4 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/DrawingsConfigManager.cs @@ -12,8 +12,8 @@ namespace osu.Game.Tournament.Screens.Drawings.Components protected override void InitialiseDefaults() { - Set(DrawingsConfig.Groups, 8, 1, 8); - Set(DrawingsConfig.TeamsPerGroup, 8, 1, 8); + SetDefault(DrawingsConfig.Groups, 8, 1, 8); + SetDefault(DrawingsConfig.TeamsPerGroup, 8, 1, 8); } public DrawingsConfigManager(Storage storage) diff --git a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs index 3ff4718b75..c7060bd538 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs @@ -345,7 +345,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components Flag.Anchor = Anchor.Centre; Flag.Origin = Anchor.Centre; - Flag.Scale = new Vector2(0.9f); + Flag.Scale = new Vector2(0.7f); InternalChildren = new Drawable[] { diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index 582f72429b..aa1be143ea 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using Newtonsoft.Json; @@ -43,10 +44,13 @@ namespace osu.Game.Tournament.Screens.Editors private void addAllCountries() { List countries; + using (Stream stream = game.Resources.GetStream("Resources/countries.json")) using (var sr = new StreamReader(stream)) countries = JsonConvert.DeserializeObject>(sr.ReadToEnd()); + Debug.Assert(countries != null); + foreach (var c in countries) Storage.Add(c); } diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs index d790f4b754..8048425ce1 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs @@ -95,7 +95,11 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components Origin = Anchor.TopRight, }, }; + } + protected override void LoadComplete() + { + base.LoadComplete(); updateDisplay(); } diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs index 4ba86dcefc..33658115cc 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs @@ -14,9 +14,21 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components { private readonly TeamScore score; + private bool showScore; + public bool ShowScore { - set => score.FadeTo(value ? 1 : 0, 200); + get => showScore; + set + { + if (showScore == value) + return; + + showScore = value; + + if (IsLoaded) + updateDisplay(); + } } public TeamDisplay(TournamentTeam team, TeamColour colour, Bindable currentTeamScore, int pointsToWin) @@ -92,5 +104,18 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateDisplay(); + FinishTransforms(true); + } + + private void updateDisplay() + { + score.FadeTo(ShowScore ? 1 : 0, 200); + } } } diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index fadb821bef..87e23e3404 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -2,18 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using System.Drawing; -using osu.Framework.Extensions.Color4Extensions; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Colour; -using osu.Game.Graphics.Cursor; -using osu.Game.Tournament.Models; +using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Platform; using osu.Game.Graphics; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; +using osu.Game.Tournament.Models; using osuTK; using osuTK.Graphics; @@ -36,7 +39,7 @@ namespace osu.Game.Tournament private LoadingSpinner loadingSpinner; [BackgroundDependencyLoader] - private void load(FrameworkConfigManager frameworkConfig) + private void load(FrameworkConfigManager frameworkConfig, GameHost host) { windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); windowMode = frameworkConfig.GetBindable(FrameworkSetting.WindowMode); @@ -48,6 +51,13 @@ namespace osu.Game.Tournament Margin = new MarginPadding(40), }); + // in order to have the OS mouse cursor visible, relative mode needs to be disabled. + // can potentially be removed when https://github.com/ppy/osu-framework/issues/4309 is resolved. + var mouseHandler = host.AvailableInputHandlers.OfType().FirstOrDefault(); + + if (mouseHandler != null) + mouseHandler.UseRelativeMode.Value = false; + loadingSpinner.Show(); BracketLoadTask.ContinueWith(_ => LoadComponentsAsync(new[] diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index d506724017..92eb7ac713 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -141,7 +141,6 @@ namespace osu.Game.Tournament /// /// Add missing player info based on user IDs. /// - /// private bool addPlayers() { bool addedInfo = false; @@ -150,7 +149,9 @@ namespace osu.Game.Tournament { foreach (var p in t.Players) { - if (string.IsNullOrEmpty(p.Username) || p.Statistics?.GlobalRank == null) + if (string.IsNullOrEmpty(p.Username) + || p.Statistics?.GlobalRank == null + || p.Statistics?.CountryRank == null) { PopulateUser(p, immediate: true); addedInfo = true; diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index cb0b3a8d09..f3434c5153 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -17,18 +17,16 @@ namespace osu.Game.Beatmaps public abstract class BeatmapConverter : IBeatmapConverter where T : HitObject { - private event Action> ObjectConverted; + private event Action> objectConverted; event Action> IBeatmapConverter.ObjectConverted { - add => ObjectConverted += value; - remove => ObjectConverted -= value; + add => objectConverted += value; + remove => objectConverted -= value; } public IBeatmap Beatmap { get; } - private CancellationToken cancellationToken; - protected BeatmapConverter(IBeatmap beatmap, Ruleset ruleset) { Beatmap = beatmap; @@ -41,8 +39,6 @@ namespace osu.Game.Beatmaps public IBeatmap Convert(CancellationToken cancellationToken = default) { - this.cancellationToken = cancellationToken; - // We always operate on a clone of the original beatmap, to not modify it game-wide return ConvertBeatmap(Beatmap.Clone(), cancellationToken); } @@ -55,19 +51,6 @@ namespace osu.Game.Beatmaps /// The converted Beatmap. protected virtual Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken) { -#pragma warning disable 618 - return ConvertBeatmap(original); -#pragma warning restore 618 - } - - /// - /// Performs the conversion of a Beatmap using this Beatmap Converter. - /// - /// The un-converted Beatmap. - /// The converted Beatmap. - [Obsolete("Use the cancellation-supporting override")] // Can be removed 20210318 - protected virtual Beatmap ConvertBeatmap(IBeatmap original) - { var beatmap = CreateBeatmap(); beatmap.BeatmapInfo = original.BeatmapInfo; @@ -92,10 +75,10 @@ namespace osu.Game.Beatmaps var converted = ConvertHitObject(obj, beatmap, cancellationToken); - if (ObjectConverted != null) + if (objectConverted != null) { converted = converted.ToList(); - ObjectConverted.Invoke(obj, converted); + objectConverted.Invoke(obj, converted); } foreach (var c in converted) @@ -121,21 +104,6 @@ namespace osu.Game.Beatmaps /// The un-converted Beatmap. /// The cancellation token. /// The converted hit object. - protected virtual IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) - { -#pragma warning disable 618 - return ConvertHitObject(original, beatmap); -#pragma warning restore 618 - } - - /// - /// Performs the conversion of a hit object. - /// This method is generally executed sequentially for all objects in a beatmap. - /// - /// The hit object to convert. - /// The un-converted Beatmap. - /// The converted hit object. - [Obsolete("Use the cancellation-supporting override")] // Can be removed 20210318 - protected virtual IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap) => Enumerable.Empty(); + protected virtual IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) => Enumerable.Empty(); } } diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 37d262abe5..6ed623d0c0 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -69,8 +69,8 @@ namespace osu.Game.Beatmaps ///
/// The to get the difficulty of. /// An optional which stops updating the star difficulty for the given . - /// A bindable that is updated to contain the star difficulty when it becomes available. - public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) + /// A bindable that is updated to contain the star difficulty when it becomes available. Will be null while in an initial calculating state (but not during updates to ruleset and mods if a stale value is already propagated). + public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) { var bindable = createBindable(beatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken); @@ -90,9 +90,9 @@ namespace osu.Game.Beatmaps /// The to get the difficulty with. If null, the 's ruleset is used. /// The s to get the difficulty with. If null, no mods will be assumed. /// An optional which stops updating the star difficulty for the given . - /// A bindable that is updated to contain the star difficulty when it becomes available. - public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, - CancellationToken cancellationToken = default) + /// A bindable that is updated to contain the star difficulty when it becomes available. Will be null while in an initial calculating state. + public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, + CancellationToken cancellationToken = default) => createBindable(beatmapInfo, rulesetInfo, mods, cancellationToken); /// @@ -103,8 +103,8 @@ namespace osu.Game.Beatmaps /// The s to get the difficulty with. /// An optional which stops computing the star difficulty. /// The . - public Task GetDifficultyAsync([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, [CanBeNull] IEnumerable mods = null, - CancellationToken cancellationToken = default) + public virtual Task GetDifficultyAsync([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, + [CanBeNull] IEnumerable mods = null, CancellationToken cancellationToken = default) { // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. rulesetInfo ??= beatmapInfo.Ruleset; @@ -313,7 +313,7 @@ namespace osu.Game.Beatmaps } } - private class BindableStarDifficulty : Bindable + private class BindableStarDifficulty : Bindable { public readonly BeatmapInfo Beatmap; public readonly CancellationToken CancellationToken; diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index a898e10e4f..36cb97e8d7 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -7,6 +7,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using Newtonsoft.Json; +using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.IO.Serialization; @@ -127,6 +128,8 @@ namespace osu.Game.Beatmaps // Metadata public string Version { get; set; } + private string versionString => string.IsNullOrEmpty(Version) ? string.Empty : $"[{Version}]"; + [JsonProperty("difficulty_rating")] public double StarDifficulty { get; set; } @@ -143,11 +146,12 @@ namespace osu.Game.Beatmaps Version }.Concat(Metadata?.SearchableTerms ?? Enumerable.Empty()).Where(s => !string.IsNullOrEmpty(s)).ToArray(); - public override string ToString() - { - string version = string.IsNullOrEmpty(Version) ? string.Empty : $"[{Version}]"; + public override string ToString() => $"{Metadata ?? BeatmapSet?.Metadata} {versionString}".Trim(); - return $"{Metadata} {version}".Trim(); + public RomanisableString ToRomanisableString() + { + var metadata = (Metadata ?? BeatmapSet?.Metadata)?.ToRomanisableString() ?? new RomanisableString(null, null); + return new RomanisableString($"{metadata.GetPreferred(true)} {versionString}".Trim(), $"{metadata.GetPreferred(false)} {versionString}".Trim()); } public bool Equals(BeatmapInfo other) diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 3c6a6ba302..00af06703d 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -20,6 +20,7 @@ using osu.Framework.IO.Stores; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Framework.Statistics; using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.Database; @@ -71,6 +72,7 @@ namespace osu.Game.Beatmaps private readonly RulesetStore rulesets; private readonly BeatmapStore beatmaps; private readonly AudioManager audioManager; + private readonly IResourceStore resources; private readonly LargeTextureStore largeTextureStore; private readonly ITrackStore trackStore; @@ -80,12 +82,13 @@ namespace osu.Game.Beatmaps [CanBeNull] private readonly BeatmapOnlineLookupQueue onlineLookupQueue; - public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, GameHost host = null, + public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false) : base(storage, contextFactory, api, new BeatmapStore(contextFactory), host) { this.rulesets = rulesets; this.audioManager = audioManager; + this.resources = resources; this.host = host; DefaultBeatmap = defaultBeatmap; @@ -112,8 +115,6 @@ namespace osu.Game.Beatmaps { var metadata = new BeatmapMetadata { - Artist = "artist", - Title = "title", Author = user, }; @@ -127,7 +128,6 @@ namespace osu.Game.Beatmaps BaseDifficulty = new BeatmapDifficulty(), Ruleset = ruleset, Metadata = metadata, - Version = "difficulty" } } }; @@ -155,7 +155,7 @@ namespace osu.Game.Beatmaps bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0); if (onlineLookupQueue != null) - await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken); + await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false); // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID. if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0)) @@ -242,7 +242,7 @@ namespace osu.Game.Beatmaps /// The to save the content against. The file referenced by will be replaced. /// The content to write. /// The beatmap content to write, null if to be omitted. - public void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) + public virtual void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) { var setInfo = info.BeatmapSet; @@ -282,23 +282,17 @@ namespace osu.Game.Beatmaps /// Retrieve a instance for the provided /// /// The beatmap to lookup. - /// The currently loaded . Allows for optimisation where elements are shared with the new beatmap. May be returned if beatmapInfo requested matches /// A instance correlating to the provided . - public WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo, WorkingBeatmap previous = null) + public virtual WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo) { - if (beatmapInfo?.ID > 0 && previous != null && previous.BeatmapInfo?.ID == beatmapInfo.ID) - return previous; - - if (beatmapInfo?.BeatmapSet == null || beatmapInfo == DefaultBeatmap?.BeatmapInfo) - return DefaultBeatmap; - - if (beatmapInfo.BeatmapSet.Files == null) + // if there are no files, presume the full beatmap info has not yet been fetched from the database. + if (beatmapInfo?.BeatmapSet?.Files.Count == 0) { - var info = beatmapInfo; - beatmapInfo = QueryBeatmap(b => b.ID == info.ID); + int lookupId = beatmapInfo.ID; + beatmapInfo = QueryBeatmap(b => b.ID == lookupId); } - if (beatmapInfo == null) + if (beatmapInfo?.BeatmapSet == null) return DefaultBeatmap; lock (workingCache) @@ -311,6 +305,9 @@ namespace osu.Game.Beatmaps workingCache.Add(working = new BeatmapManagerWorkingBeatmap(beatmapInfo, this)); + // best effort; may be higher than expected. + GlobalStatistics.Get(nameof(Beatmaps), $"Cached {nameof(WorkingBeatmap)}s").Value = workingCache.Count(); + return working; } } @@ -505,6 +502,7 @@ namespace osu.Game.Beatmaps ITrackStore IBeatmapResourceProvider.Tracks => trackStore; AudioManager IStorageResourceProvider.AudioManager => audioManager; IResourceStore IStorageResourceProvider.Files => Files.Store; + IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore); #endregion @@ -525,6 +523,8 @@ namespace osu.Game.Beatmaps protected override IBeatmap GetBeatmap() => beatmap; protected override Texture GetBackground() => null; protected override Track GetBeatmapTrack() => null; + protected override ISkin GetSkin() => null; + public override Stream GetStream(string storagePath) => null; } } diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs index 7c4b344c9e..5dff4fe282 100644 --- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading; @@ -83,6 +82,12 @@ namespace osu.Game.Beatmaps beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID; beatmap.OnlineBeatmapID = res.OnlineBeatmapID; + if (beatmap.Metadata != null) + beatmap.Metadata.AuthorID = res.AuthorID; + + if (beatmap.BeatmapSet.Metadata != null) + beatmap.BeatmapSet.Metadata.AuthorID = res.AuthorID; + LogForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}."); } } @@ -157,7 +162,7 @@ namespace osu.Game.Beatmaps using (var cmd = db.CreateCommand()) { - cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path"; + cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved, user_id FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path"; cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash)); cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value)); @@ -174,6 +179,12 @@ namespace osu.Game.Beatmaps beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0); beatmap.OnlineBeatmapID = reader.GetInt32(1); + if (beatmap.Metadata != null) + beatmap.Metadata.AuthorID = reader.GetInt32(3); + + if (beatmap.BeatmapSet.Metadata != null) + beatmap.BeatmapSet.Metadata.AuthorID = reader.GetInt32(3); + LogForModel(set, $"Cached local retrieval for {beatmap}."); return true; } @@ -194,17 +205,6 @@ namespace osu.Game.Beatmaps cacheDownloadRequest?.Dispose(); updateScheduler?.Dispose(); } - - [Serializable] - [SuppressMessage("ReSharper", "InconsistentNaming")] - private class CachedOnlineBeatmapLookup - { - public int approved { get; set; } - - public int? beatmapset_id { get; set; } - - public int? beatmap_id { get; set; } - } } } } diff --git a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs index 62cf29dc03..d78ffbbfb6 100644 --- a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs @@ -3,7 +3,7 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Linq; +using System.IO; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; @@ -36,7 +36,7 @@ namespace osu.Game.Beatmaps try { - using (var stream = new LineBufferedReader(resources.Files.GetStream(getPathForFile(BeatmapInfo.Path)))) + using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path)))) return Decoder.GetDecoder(stream).Decode(stream); } catch (Exception e) @@ -46,8 +46,6 @@ namespace osu.Game.Beatmaps } } - private string getPathForFile(string filename) => BeatmapSetInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; - protected override bool BackgroundStillValid(Texture b) => false; // bypass lazy logic. we want to return a new background each time for refcounting purposes. protected override Texture GetBackground() @@ -57,7 +55,7 @@ namespace osu.Game.Beatmaps try { - return resources.LargeTextureStore.Get(getPathForFile(Metadata.BackgroundFile)); + return resources.LargeTextureStore.Get(BeatmapSetInfo.GetPathForFile(Metadata.BackgroundFile)); } catch (Exception e) { @@ -73,7 +71,7 @@ namespace osu.Game.Beatmaps try { - return resources.Tracks.Get(getPathForFile(Metadata.AudioFile)); + return resources.Tracks.Get(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile)); } catch (Exception e) { @@ -89,7 +87,7 @@ namespace osu.Game.Beatmaps try { - var trackData = resources.Files.GetStream(getPathForFile(Metadata.AudioFile)); + var trackData = GetStream(BeatmapSetInfo.GetPathForFile(Metadata.AudioFile)); return trackData == null ? null : new Waveform(trackData); } catch (Exception e) @@ -105,7 +103,7 @@ namespace osu.Game.Beatmaps try { - using (var stream = new LineBufferedReader(resources.Files.GetStream(getPathForFile(BeatmapInfo.Path)))) + using (var stream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path)))) { var decoder = Decoder.GetDecoder(stream); @@ -114,7 +112,7 @@ namespace osu.Game.Beatmaps storyboard = decoder.Decode(stream); else { - using (var secondaryStream = new LineBufferedReader(resources.Files.GetStream(getPathForFile(BeatmapSetInfo.StoryboardFile)))) + using (var secondaryStream = new LineBufferedReader(GetStream(BeatmapSetInfo.GetPathForFile(BeatmapSetInfo.StoryboardFile)))) storyboard = decoder.Decode(stream, secondaryStream); } } @@ -142,6 +140,8 @@ namespace osu.Game.Beatmaps return null; } } + + public override Stream GetStream(string storagePath) => resources.Files.GetStream(storagePath); } } } diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index 367f612dc8..bfc0236db3 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using Newtonsoft.Json; +using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Users; @@ -19,8 +20,13 @@ namespace osu.Game.Beatmaps public int ID { get; set; } public string Title { get; set; } + + [JsonProperty("title_unicode")] public string TitleUnicode { get; set; } + public string Artist { get; set; } + + [JsonProperty("artist_unicode")] public string ArtistUnicode { get; set; } [JsonIgnore] @@ -29,6 +35,21 @@ namespace osu.Game.Beatmaps [JsonIgnore] public List BeatmapSets { get; set; } + /// + /// Helper property to deserialize a username to . + /// + [JsonProperty(@"user_id")] + [Column("AuthorID")] + public int AuthorID + { + get => Author?.Id ?? 1; + set + { + Author ??= new User(); + Author.Id = value; + } + } + /// /// Helper property to deserialize a username to . /// @@ -37,7 +58,11 @@ namespace osu.Game.Beatmaps public string AuthorString { get => Author?.Username; - set => Author = new User { Username = value }; + set + { + Author ??= new User(); + Author.Username = value; + } } /// @@ -66,6 +91,12 @@ namespace osu.Game.Beatmaps return $"{Artist} - {Title} {author}".Trim(); } + public RomanisableString ToRomanisableString() + { + string author = Author == null ? string.Empty : $"({Author})"; + return new RomanisableString($"{ArtistUnicode} - {TitleUnicode} {author}".Trim(), $"{Artist} - {Title} {author}".Trim()); + } + [JsonIgnore] public string[] SearchableTerms => new[] { diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 7bc1c8c7b9..3b1ff4ced0 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Testing; using osu.Game.Database; @@ -31,6 +32,9 @@ namespace osu.Game.Beatmaps public List Beatmaps { get; set; } + [NotNull] + public List Files { get; set; } = new List(); + [NotMapped] public BeatmapSetOnlineInfo OnlineInfo { get; set; } @@ -57,9 +61,14 @@ namespace osu.Game.Beatmaps public string Hash { get; set; } - public string StoryboardFile => Files?.Find(f => f.Filename.EndsWith(".osb", StringComparison.OrdinalIgnoreCase))?.Filename; + public string StoryboardFile => Files.Find(f => f.Filename.EndsWith(".osb", StringComparison.OrdinalIgnoreCase))?.Filename; - public List Files { get; set; } + /// + /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. + /// The path returned is relative to the user file storage. + /// + /// The name of the file to get the storage path of. + public string GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; public override string ToString() => Metadata?.ToString() ?? base.ToString(); diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs index 5864975a2e..ae5a44cfcd 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs @@ -14,4 +14,10 @@ namespace osu.Game.Beatmaps Qualified = 3, Loved = 4, } + + public static class BeatmapSetOnlineStatusExtensions + { + public static bool GrantsPerformancePoints(this BeatmapSetOnlineStatus status) + => status == BeatmapSetOnlineStatus.Ranked || status == BeatmapSetOnlineStatus.Approved; + } } diff --git a/osu.Game/Beatmaps/BeatmapStatistic.cs b/osu.Game/Beatmaps/BeatmapStatistic.cs index 9d87a20d60..7d7ba09fcf 100644 --- a/osu.Game/Beatmaps/BeatmapStatistic.cs +++ b/osu.Game/Beatmaps/BeatmapStatistic.cs @@ -3,16 +3,11 @@ using System; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osuTK; namespace osu.Game.Beatmaps { public class BeatmapStatistic { - [Obsolete("Use CreateIcon instead")] // can be removed 20210203 - public IconUsage Icon = FontAwesome.Regular.QuestionCircle; - /// /// A function to create the icon for display purposes. Use default icons available via whenever possible for conformity. /// @@ -20,12 +15,5 @@ namespace osu.Game.Beatmaps public string Content; public string Name; - - public BeatmapStatistic() - { -#pragma warning disable 618 - CreateIcon = () => new SpriteIcon { Icon = Icon, Scale = new Vector2(0.7f) }; -#pragma warning restore 618 - } } } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index 5cc60a5758..d3a4b635f5 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -7,6 +7,8 @@ using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Lists; +using osu.Framework.Utils; +using osu.Game.Screens.Edit; namespace osu.Game.Beatmaps.ControlPoints { @@ -160,6 +162,58 @@ namespace osu.Game.Beatmaps.ControlPoints groups.Remove(group); } + /// + /// Returns the time on the given beat divisor closest to the given time. + /// + /// The time to find the closest snapped time to. + /// The beat divisor to snap to. + /// An optional reference point to use for timing point lookup. + public double GetClosestSnappedTime(double time, int beatDivisor, double? referenceTime = null) + { + var timingPoint = TimingPointAt(referenceTime ?? time); + return getClosestSnappedTime(timingPoint, time, beatDivisor); + } + + /// + /// Returns the time on *ANY* valid beat divisor, favouring the divisor closest to the given time. + /// + /// The time to find the closest snapped time to. + public double GetClosestSnappedTime(double time) => GetClosestSnappedTime(time, GetClosestBeatDivisor(time)); + + /// + /// Returns the beat snap divisor closest to the given time. If two are equally close, the smallest divisor is returned. + /// + /// The time to find the closest beat snap divisor to. + /// An optional reference point to use for timing point lookup. + public int GetClosestBeatDivisor(double time, double? referenceTime = null) + { + TimingControlPoint timingPoint = TimingPointAt(referenceTime ?? time); + + int closestDivisor = 0; + double closestTime = double.MaxValue; + + foreach (int divisor in BindableBeatDivisor.VALID_DIVISORS) + { + double distanceFromSnap = Math.Abs(time - getClosestSnappedTime(timingPoint, time, divisor)); + + if (Precision.DefinitelyBigger(closestTime, distanceFromSnap)) + { + closestDivisor = divisor; + closestTime = distanceFromSnap; + } + } + + return closestDivisor; + } + + private static double getClosestSnappedTime(TimingControlPoint timingPoint, double time, int beatDivisor) + { + var beatLength = timingPoint.BeatLength / beatDivisor; + var beatLengths = (int)Math.Round((time - timingPoint.Time) / beatLength, MidpointRounding.AwayFromZero); + + return timingPoint.Time + beatLengths * beatLength; + } + /// /// Binary searches one of the control point lists to find the active control point at . /// Includes logic for returning a specific point when no matching point is found. diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index 0bc5605051..8a6cfaf688 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -19,13 +19,13 @@ namespace osu.Game.Beatmaps.ControlPoints /// public readonly BindableDouble SpeedMultiplierBindable = new BindableDouble(1) { - Precision = 0.1, + Precision = 0.01, Default = 1, MinValue = 0.1, MaxValue = 10 }; - public override Color4 GetRepresentingColour(OsuColour colours) => colours.GreenDark; + public override Color4 GetRepresentingColour(OsuColour colours) => colours.Lime1; /// /// The speed multiplier at this control point. diff --git a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs index 580642f593..ec20328fab 100644 --- a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs @@ -20,7 +20,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// private const double default_beat_length = 60000.0 / 60.0; - public override Color4 GetRepresentingColour(OsuColour colours) => colours.YellowDark; + public override Color4 GetRepresentingColour(OsuColour colours) => colours.Orange1; public static readonly TimingControlPoint DEFAULT = new TimingControlPoint { diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index 96e18f120a..c62b803d1a 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -151,7 +151,7 @@ namespace osu.Game.Beatmaps.Drawables this.mods = mods; } - private IBindable localStarDifficulty; + private IBindable localStarDifficulty; [BackgroundDependencyLoader] private void load() @@ -160,7 +160,11 @@ namespace osu.Game.Beatmaps.Drawables localStarDifficulty = ruleset != null ? difficultyCache.GetBindableDifficulty(beatmap, ruleset, mods, difficultyCancellation.Token) : difficultyCache.GetBindableDifficulty(beatmap, difficultyCancellation.Token); - localStarDifficulty.BindValueChanged(difficulty => StarDifficulty.Value = difficulty.NewValue); + localStarDifficulty.BindValueChanged(d => + { + if (d.NewValue is StarDifficulty diff) + StarDifficulty.Value = diff; + }); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs index 6c229755e7..7248c9213c 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.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.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -8,78 +9,52 @@ using osu.Game.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class UpdateableBeatmapSetCover : Container + public class UpdateableBeatmapSetCover : ModelBackedDrawable { - private Drawable displayedCover; - - private BeatmapSetInfo beatmapSet; + private readonly BeatmapSetCoverType coverType; public BeatmapSetInfo BeatmapSet { - get => beatmapSet; - set - { - if (value == beatmapSet) return; - - beatmapSet = value; - - if (IsLoaded) - updateCover(); - } + get => Model; + set => Model = value; } - private BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover; - - public BeatmapSetCoverType CoverType + public new bool Masking { - get => coverType; - set - { - if (value == coverType) return; - - coverType = value; - - if (IsLoaded) - updateCover(); - } + get => base.Masking; + set => base.Masking = value; } - public UpdateableBeatmapSetCover() + public UpdateableBeatmapSetCover(BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover) { - Child = new Box + this.coverType = coverType; + + InternalChild = new Box { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.2f), }; } - protected override void LoadComplete() - { - base.LoadComplete(); - updateCover(); - } + protected override double LoadDelay => 500; - private void updateCover() - { - displayedCover?.FadeOut(400); - displayedCover?.Expire(); - displayedCover = null; + protected override double TransformDuration => 400; - if (beatmapSet != null) + protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) + => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad); + + protected override Drawable CreateDrawable(BeatmapSetInfo model) + { + if (model == null) + return null; + + return new BeatmapSetCover(model, coverType) { - Add(displayedCover = new DelayedLoadUnloadWrapper(() => - { - var cover = new BeatmapSetCover(beatmapSet, coverType) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fill, - }; - cover.OnLoadComplete += d => d.FadeInFromZero(400, Easing.Out); - return cover; - })); - } + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + }; } } } diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index c114358771..ea7f45e53f 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Threading; using JetBrains.Annotations; using osu.Framework.Audio; @@ -14,6 +15,7 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; +using osu.Game.Skinning; namespace osu.Game.Beatmaps { @@ -48,6 +50,10 @@ namespace osu.Game.Beatmaps protected override Track GetBeatmapTrack() => GetVirtualTrack(); + protected override ISkin GetSkin() => null; + + public override Stream GetStream(string storagePath) => null; + private class DummyRulesetInfo : RulesetInfo { public override Ruleset CreateInstance() => new DummyRuleset(); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 37ab489da5..40bc75e847 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using osu.Framework.Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Timing; @@ -66,16 +67,14 @@ namespace osu.Game.Beatmaps.Formats protected override void ParseLine(Beatmap beatmap, Section section, string line) { - var strippedLine = StripComments(line); - switch (section) { case Section.General: - handleGeneral(strippedLine); + handleGeneral(line); return; case Section.Editor: - handleEditor(strippedLine); + handleEditor(line); return; case Section.Metadata: @@ -83,19 +82,19 @@ namespace osu.Game.Beatmaps.Formats return; case Section.Difficulty: - handleDifficulty(strippedLine); + handleDifficulty(line); return; case Section.Events: - handleEvent(strippedLine); + handleEvent(line); return; case Section.TimingPoints: - handleTimingPoint(strippedLine); + handleTimingPoint(line); return; case Section.HitObjects: - handleHitObject(strippedLine); + handleHitObject(line); return; } @@ -348,8 +347,8 @@ namespace osu.Game.Beatmaps.Formats if (split.Length >= 8) { LegacyEffectFlags effectFlags = (LegacyEffectFlags)Parsing.ParseInt(split[7]); - kiaiMode = effectFlags.HasFlag(LegacyEffectFlags.Kiai); - omitFirstBarSignature = effectFlags.HasFlag(LegacyEffectFlags.OmitFirstBarLine); + kiaiMode = effectFlags.HasFlagFast(LegacyEffectFlags.Kiai); + omitFirstBarSignature = effectFlags.HasFlagFast(LegacyEffectFlags.OmitFirstBarLine); } string stringSampleSet = sampleSet.ToString().ToLowerInvariant(); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index df940e8c8e..acbf57d25f 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -273,7 +273,7 @@ namespace osu.Game.Beatmaps.Formats if (hitObject is IHasPath path) { addPathData(writer, path, position); - writer.Write(getSampleBank(hitObject.Samples, zeroBanks: true)); + writer.Write(getSampleBank(hitObject.Samples)); } else { @@ -329,7 +329,26 @@ namespace osu.Game.Beatmaps.Formats if (point.Type.Value != null) { - if (point.Type.Value != lastType) + // We've reached a new (explicit) segment! + + // Explicit segments have a new format in which the type is injected into the middle of the control point string. + // To preserve compatibility with osu-stable as much as possible, explicit segments with the same type are converted to use implicit segments by duplicating the control point. + // One exception are consecutive perfect curves, which aren't supported in osu!stable and can lead to decoding issues if encoded as implicit segments + bool needsExplicitSegment = point.Type.Value != lastType || point.Type.Value == PathType.PerfectCurve; + + // Another exception to this is when the last two control points of the last segment were duplicated. This is not a scenario supported by osu!stable. + // Lazer does not add implicit segments for the last two control points of _any_ explicit segment, so an explicit segment is forced in order to maintain consistency with the decoder. + if (i > 1) + { + // We need to use the absolute control point position to determine equality, otherwise floating point issues may arise. + Vector2 p1 = position + pathData.Path.ControlPoints[i - 1].Position.Value; + Vector2 p2 = position + pathData.Path.ControlPoints[i - 2].Position.Value; + + if ((int)p1.X == (int)p2.X && (int)p1.Y == (int)p2.Y) + needsExplicitSegment = true; + } + + if (needsExplicitSegment) { switch (point.Type.Value) { @@ -401,15 +420,15 @@ namespace osu.Game.Beatmaps.Formats writer.Write(FormattableString.Invariant($"{endTimeData.EndTime}{suffix}")); } - private string getSampleBank(IList samples, bool banksOnly = false, bool zeroBanks = false) + private string getSampleBank(IList samples, bool banksOnly = false) { LegacySampleBank normalBank = toLegacySampleBank(samples.SingleOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank); LegacySampleBank addBank = toLegacySampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name) && s.Name != HitSampleInfo.HIT_NORMAL)?.Bank); StringBuilder sb = new StringBuilder(); - sb.Append(FormattableString.Invariant($"{(zeroBanks ? 0 : (int)normalBank)}:")); - sb.Append(FormattableString.Invariant($"{(zeroBanks ? 0 : (int)addBank)}")); + sb.Append(FormattableString.Invariant($"{(int)normalBank}:")); + sb.Append(FormattableString.Invariant($"{(int)addBank}")); if (!banksOnly) { @@ -471,9 +490,6 @@ namespace osu.Game.Beatmaps.Formats private string toLegacyCustomSampleBank(HitSampleInfo hitSampleInfo) { - if (hitSampleInfo == null) - return "0"; - if (hitSampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy) return legacy.CustomSampleBank.ToString(CultureInfo.InvariantCulture); diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 2fb24c24e0..b39890084f 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -36,6 +36,14 @@ namespace osu.Game.Beatmaps.Formats if (ShouldSkipLine(line)) continue; + if (section != Section.Metadata) + { + // comments should not be stripped from metadata lines, as the song metadata may contain "//" as valid data. + line = StripComments(line); + } + + line = line.TrimEnd(); + if (line.StartsWith('[') && line.EndsWith(']')) { if (!Enum.TryParse(line[1..^1], out section)) @@ -71,8 +79,6 @@ namespace osu.Game.Beatmaps.Formats protected virtual void ParseLine(T output, Section section, string line) { - line = StripComments(line); - switch (section) { case Section.Colours: diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index b9bf6823b5..6301c42deb 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -45,8 +45,6 @@ namespace osu.Game.Beatmaps.Formats protected override void ParseLine(Storyboard storyboard, Section section, string line) { - line = StripComments(line); - switch (section) { case Section.General: diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 9847ea020a..769b33009a 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -44,7 +44,6 @@ namespace osu.Game.Beatmaps /// /// Returns statistics for the contained in this beatmap. /// - /// IEnumerable GetStatistics(); /// diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs index bcd94d76fd..a916b37b85 100644 --- a/osu.Game/Beatmaps/IWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Game.Rulesets; @@ -41,6 +42,11 @@ namespace osu.Game.Beatmaps /// ISkin Skin { get; } + /// + /// Retrieves the which this has loaded. + /// + Track Track { get; } + /// /// Constructs a playable from using the applicable converters for a specific . /// @@ -67,5 +73,11 @@ namespace osu.Game.Beatmaps /// /// A fresh track instance, which will also be available via . Track LoadTrack(); + + /// + /// Returns the stream of the file from the given storage path. + /// + /// The storage path to the file. + Stream GetStream(string storagePath); } } diff --git a/osu.Game/Beatmaps/Timing/TimeSignatures.cs b/osu.Game/Beatmaps/Timing/TimeSignatures.cs index 147f6239b4..33e6342ae6 100644 --- a/osu.Game/Beatmaps/Timing/TimeSignatures.cs +++ b/osu.Game/Beatmaps/Timing/TimeSignatures.cs @@ -1,11 +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.ComponentModel; + namespace osu.Game.Beatmaps.Timing { public enum TimeSignatures { + [Description("4/4")] SimpleQuadruple = 4, + + [Description("3/4")] SimpleTriple = 3 } } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index aab8ff6bd6..0a55678fb7 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -12,7 +13,6 @@ using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; -using osu.Framework.Statistics; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -34,8 +34,6 @@ namespace osu.Game.Beatmaps protected AudioManager AudioManager { get; } - private static readonly GlobalStatistic total_count = GlobalStatistics.Get(nameof(Beatmaps), $"Total {nameof(WorkingBeatmap)}s"); - protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager) { AudioManager = audioManager; @@ -47,8 +45,6 @@ namespace osu.Game.Beatmaps waveform = new RecyclableLazy(GetWaveform); storyboard = new RecyclableLazy(GetStoryboard); skin = new RecyclableLazy(GetSkin); - - total_count.Value++; } protected virtual Track GetVirtualTrack(double emptyLength = 0) @@ -328,13 +324,10 @@ namespace osu.Game.Beatmaps public bool SkinLoaded => skin.IsResultAvailable; public ISkin Skin => skin.Value; - protected virtual ISkin GetSkin() => new DefaultSkin(); + protected abstract ISkin GetSkin(); private readonly RecyclableLazy skin; - ~WorkingBeatmap() - { - total_count.Value--; - } + public abstract Stream GetStream(string storagePath); public class RecyclableLazy { diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs index bb743d4ccc..1eceb56e33 100644 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ b/osu.Game/Collections/CollectionFilterDropdown.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -121,7 +122,7 @@ namespace osu.Game.Collections Current.TriggerChange(); } - protected override string GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName.Value; + protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName.Value; protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader().With(d => { @@ -139,7 +140,7 @@ namespace osu.Game.Collections public readonly Bindable SelectedItem = new Bindable(); private readonly Bindable collectionName = new Bindable(); - protected override string Label + protected override LocalisableString Label { get => base.Label; set { } // See updateText(). diff --git a/osu.Game/Collections/CollectionFilterMenuItem.cs b/osu.Game/Collections/CollectionFilterMenuItem.cs index fe79358223..0617996872 100644 --- a/osu.Game/Collections/CollectionFilterMenuItem.cs +++ b/osu.Game/Collections/CollectionFilterMenuItem.cs @@ -36,7 +36,19 @@ namespace osu.Game.Collections } public bool Equals(CollectionFilterMenuItem other) - => other != null && CollectionName.Value == other.CollectionName.Value; + { + if (other == null) + return false; + + // 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 == other.Collection; + + // 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.Value == other.CollectionName.Value; + } public override int GetHashCode() => CollectionName.Value.GetHashCode(); } diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index a65d9a415d..086cc573d5 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -8,13 +8,13 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.IO; using osu.Game.IO.Legacy; using osu.Game.Overlays.Notifications; @@ -38,8 +38,6 @@ namespace osu.Game.Collections public readonly BindableList Collections = new BindableList(); - public bool SupportsImportFromStable => RuntimeInfo.IsDesktop; - [Resolved] private GameHost host { get; set; } @@ -60,8 +58,13 @@ namespace osu.Game.Collections if (storage.Exists(database_name)) { + List beatmapCollections; + using (var stream = storage.GetStream(database_name)) - importCollections(readCollections(stream)); + beatmapCollections = readCollections(stream); + + // intentionally fire-and-forget async. + importCollections(beatmapCollections); } } @@ -96,25 +99,12 @@ namespace osu.Game.Collections /// public Action PostNotification { protected get; set; } - /// - /// Set a storage with access to an osu-stable install for import purposes. - /// - public Func GetStableStorage { private get; set; } - /// /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. /// - public Task ImportFromStableAsync() + public Task ImportFromStableAsync(StableStorage stableStorage) { - var stable = GetStableStorage?.Invoke(); - - if (stable == null) - { - Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error); - return Task.CompletedTask; - } - - if (!stable.Exists(database_name)) + if (!stableStorage.Exists(database_name)) { // This handles situations like when the user does not have a collections.db file Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); @@ -123,8 +113,8 @@ namespace osu.Game.Collections return Task.Run(async () => { - using (var stream = stable.GetStream(database_name)) - await Import(stream); + using (var stream = stableStorage.GetStream(database_name)) + await Import(stream).ConfigureAwait(false); }); } @@ -138,10 +128,10 @@ namespace osu.Game.Collections PostNotification?.Invoke(notification); - var collection = readCollections(stream, notification); - await importCollections(collection); + var collections = readCollections(stream, notification); + await importCollections(collections).ConfigureAwait(false); - notification.CompletionText = $"Imported {collection.Count} collections"; + notification.CompletionText = $"Imported {collections.Count} collections"; notification.State = ProgressNotificationState.Completed; } @@ -155,7 +145,7 @@ namespace osu.Game.Collections { foreach (var newCol in newCollections) { - var existing = Collections.FirstOrDefault(c => c.Name == newCol.Name); + var existing = Collections.FirstOrDefault(c => c.Name.Value == newCol.Name.Value); if (existing == null) Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index d0fa45bb7a..43bbd725c3 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics; -using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; @@ -24,126 +23,126 @@ namespace osu.Game.Configuration protected override void InitialiseDefaults() { // UI/selection defaults - Set(OsuSetting.Ruleset, 0, 0, int.MaxValue); - Set(OsuSetting.Skin, 0, -1, int.MaxValue); + SetDefault(OsuSetting.Ruleset, 0, 0, int.MaxValue); + SetDefault(OsuSetting.Skin, 0, -1, int.MaxValue); - Set(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); - Set(OsuSetting.BeatmapDetailModsFilter, false); + SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); + SetDefault(OsuSetting.BeatmapDetailModsFilter, false); - Set(OsuSetting.ShowConvertedBeatmaps, true); - Set(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1); - Set(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1); + SetDefault(OsuSetting.ShowConvertedBeatmaps, true); + SetDefault(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1); + SetDefault(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1); - Set(OsuSetting.SongSelectGroupingMode, GroupMode.All); - Set(OsuSetting.SongSelectSortingMode, SortMode.Title); + SetDefault(OsuSetting.SongSelectGroupingMode, GroupMode.All); + SetDefault(OsuSetting.SongSelectSortingMode, SortMode.Title); - Set(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); + SetDefault(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); - Set(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); + SetDefault(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); // Online settings - Set(OsuSetting.Username, string.Empty); - Set(OsuSetting.Token, string.Empty); + SetDefault(OsuSetting.Username, string.Empty); + SetDefault(OsuSetting.Token, string.Empty); - Set(OsuSetting.AutomaticallyDownloadWhenSpectating, false); + SetDefault(OsuSetting.AutomaticallyDownloadWhenSpectating, false); - Set(OsuSetting.SavePassword, false).ValueChanged += enabled => + SetDefault(OsuSetting.SavePassword, false).ValueChanged += enabled => { - if (enabled.NewValue) Set(OsuSetting.SaveUsername, true); + if (enabled.NewValue) SetValue(OsuSetting.SaveUsername, true); }; - Set(OsuSetting.SaveUsername, true).ValueChanged += enabled => + SetDefault(OsuSetting.SaveUsername, true).ValueChanged += enabled => { - if (!enabled.NewValue) Set(OsuSetting.SavePassword, false); + if (!enabled.NewValue) SetValue(OsuSetting.SavePassword, false); }; - Set(OsuSetting.ExternalLinkWarning, true); - Set(OsuSetting.PreferNoVideo, false); + SetDefault(OsuSetting.ExternalLinkWarning, true); + SetDefault(OsuSetting.PreferNoVideo, false); - Set(OsuSetting.ShowOnlineExplicitContent, false); + SetDefault(OsuSetting.ShowOnlineExplicitContent, false); // Audio - Set(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); + SetDefault(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); - Set(OsuSetting.MenuVoice, true); - Set(OsuSetting.MenuMusic, true); + SetDefault(OsuSetting.MenuVoice, true); + SetDefault(OsuSetting.MenuMusic, true); - Set(OsuSetting.AudioOffset, 0, -500.0, 500.0, 1); + SetDefault(OsuSetting.AudioOffset, 0, -500.0, 500.0, 1); // Input - Set(OsuSetting.MenuCursorSize, 1.0f, 0.5f, 2f, 0.01f); - Set(OsuSetting.GameplayCursorSize, 1.0f, 0.1f, 2f, 0.01f); - Set(OsuSetting.AutoCursorSize, false); + SetDefault(OsuSetting.MenuCursorSize, 1.0f, 0.5f, 2f, 0.01f); + SetDefault(OsuSetting.GameplayCursorSize, 1.0f, 0.1f, 2f, 0.01f); + SetDefault(OsuSetting.AutoCursorSize, false); - Set(OsuSetting.MouseDisableButtons, false); - Set(OsuSetting.MouseDisableWheel, false); - Set(OsuSetting.ConfineMouseMode, OsuConfineMouseMode.DuringGameplay); + SetDefault(OsuSetting.MouseDisableButtons, false); + SetDefault(OsuSetting.MouseDisableWheel, false); + SetDefault(OsuSetting.ConfineMouseMode, OsuConfineMouseMode.DuringGameplay); // Graphics - Set(OsuSetting.ShowFpsDisplay, false); + SetDefault(OsuSetting.ShowFpsDisplay, false); - Set(OsuSetting.ShowStoryboard, true); - Set(OsuSetting.BeatmapSkins, true); - Set(OsuSetting.BeatmapColours, true); - Set(OsuSetting.BeatmapHitsounds, true); + SetDefault(OsuSetting.ShowStoryboard, true); + SetDefault(OsuSetting.BeatmapSkins, true); + SetDefault(OsuSetting.BeatmapColours, true); + SetDefault(OsuSetting.BeatmapHitsounds, true); - Set(OsuSetting.CursorRotation, true); + SetDefault(OsuSetting.CursorRotation, true); - Set(OsuSetting.MenuParallax, true); + SetDefault(OsuSetting.MenuParallax, true); // Gameplay - Set(OsuSetting.DimLevel, 0.8, 0, 1, 0.01); - Set(OsuSetting.BlurLevel, 0, 0, 1, 0.01); - Set(OsuSetting.LightenDuringBreaks, true); + SetDefault(OsuSetting.DimLevel, 0.8, 0, 1, 0.01); + SetDefault(OsuSetting.BlurLevel, 0, 0, 1, 0.01); + SetDefault(OsuSetting.LightenDuringBreaks, true); - Set(OsuSetting.HitLighting, true); + SetDefault(OsuSetting.HitLighting, true); - Set(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Always); - Set(OsuSetting.ShowProgressGraph, true); - Set(OsuSetting.ShowHealthDisplayWhenCantFail, true); - Set(OsuSetting.FadePlayfieldWhenHealthLow, true); - Set(OsuSetting.KeyOverlay, false); - Set(OsuSetting.PositionalHitSounds, true); - Set(OsuSetting.AlwaysPlayFirstComboBreak, true); - Set(OsuSetting.ScoreMeter, ScoreMeterType.HitErrorBoth); + SetDefault(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Always); + SetDefault(OsuSetting.ShowProgressGraph, true); + SetDefault(OsuSetting.ShowHealthDisplayWhenCantFail, true); + SetDefault(OsuSetting.FadePlayfieldWhenHealthLow, true); + SetDefault(OsuSetting.KeyOverlay, false); + SetDefault(OsuSetting.PositionalHitSounds, true); + SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true); - Set(OsuSetting.FloatingComments, false); + SetDefault(OsuSetting.FloatingComments, false); - Set(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised); + SetDefault(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised); - Set(OsuSetting.IncreaseFirstObjectVisibility, true); - Set(OsuSetting.GameplayDisableWinKey, true); + SetDefault(OsuSetting.IncreaseFirstObjectVisibility, true); + SetDefault(OsuSetting.GameplayDisableWinKey, true); // Update - Set(OsuSetting.ReleaseStream, ReleaseStream.Lazer); + SetDefault(OsuSetting.ReleaseStream, ReleaseStream.Lazer); - Set(OsuSetting.Version, string.Empty); + SetDefault(OsuSetting.Version, string.Empty); - Set(OsuSetting.ScreenshotFormat, ScreenshotFormat.Jpg); - Set(OsuSetting.ScreenshotCaptureMenuCursor, false); + SetDefault(OsuSetting.ScreenshotFormat, ScreenshotFormat.Jpg); + SetDefault(OsuSetting.ScreenshotCaptureMenuCursor, false); - Set(OsuSetting.SongSelectRightMouseScroll, false); + SetDefault(OsuSetting.SongSelectRightMouseScroll, false); - Set(OsuSetting.Scaling, ScalingMode.Off); + SetDefault(OsuSetting.Scaling, ScalingMode.Off); - Set(OsuSetting.ScalingSizeX, 0.8f, 0.2f, 1f); - Set(OsuSetting.ScalingSizeY, 0.8f, 0.2f, 1f); + SetDefault(OsuSetting.ScalingSizeX, 0.8f, 0.2f, 1f); + SetDefault(OsuSetting.ScalingSizeY, 0.8f, 0.2f, 1f); - Set(OsuSetting.ScalingPositionX, 0.5f, 0f, 1f); - Set(OsuSetting.ScalingPositionY, 0.5f, 0f, 1f); + SetDefault(OsuSetting.ScalingPositionX, 0.5f, 0f, 1f); + SetDefault(OsuSetting.ScalingPositionY, 0.5f, 0f, 1f); - Set(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); + SetDefault(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); - Set(OsuSetting.UIHoldActivationDelay, 200f, 0f, 500f, 50f); + SetDefault(OsuSetting.UIHoldActivationDelay, 200f, 0f, 500f, 50f); - Set(OsuSetting.IntroSequence, IntroSequence.Triangles); + SetDefault(OsuSetting.IntroSequence, IntroSequence.Triangles); - Set(OsuSetting.MenuBackgroundSource, BackgroundSource.Skin); - Set(OsuSetting.SeasonalBackgroundMode, SeasonalBackgroundMode.Sometimes); + SetDefault(OsuSetting.MenuBackgroundSource, BackgroundSource.Skin); + SetDefault(OsuSetting.SeasonalBackgroundMode, SeasonalBackgroundMode.Sometimes); - Set(OsuSetting.DiscordRichPresence, DiscordRichPresenceMode.Full); + SetDefault(OsuSetting.DiscordRichPresence, DiscordRichPresenceMode.Full); - Set(OsuSetting.EditorWaveformOpacity, 1f); + SetDefault(OsuSetting.EditorWaveformOpacity, 0.25f); + SetDefault(OsuSetting.EditorHitAnimations, false); } public OsuConfigManager(Storage storage) @@ -169,14 +168,9 @@ namespace osu.Game.Configuration int combined = (year * 10000) + monthDay; - if (combined < 20200305) + if (combined < 20210413) { - // the maximum value of this setting was changed. - // if we don't manually increase this, it causes song select to filter out beatmaps the user expects to see. - var maxStars = (BindableDouble)GetOriginalBindable(OsuSetting.DisplayStarsMaximum); - - if (maxStars.Value == 10) - maxStars.Value = maxStars.MaxValue; + SetValue(OsuSetting.EditorWaveformOpacity, 0.25f); } } @@ -218,7 +212,6 @@ namespace osu.Game.Configuration KeyOverlay, PositionalHitSounds, AlwaysPlayFirstComboBreak, - ScoreMeter, FloatingComments, HUDVisibilityMode, ShowProgressGraph, @@ -272,6 +265,7 @@ namespace osu.Game.Configuration GameplayDisableWinKey, SeasonalBackgroundMode, EditorWaveformOpacity, + EditorHitAnimations, DiscordRichPresence, AutomaticallyDownloadWhenSpectating, ShowOnlineExplicitContent, diff --git a/osu.Game/Configuration/ScoreMeterType.cs b/osu.Game/Configuration/ScoreMeterType.cs deleted file mode 100644 index ddbd2327c2..0000000000 --- a/osu.Game/Configuration/ScoreMeterType.cs +++ /dev/null @@ -1,37 +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.ComponentModel; - -namespace osu.Game.Configuration -{ - public enum ScoreMeterType - { - [Description("None")] - None, - - [Description("Hit Error (left)")] - HitErrorLeft, - - [Description("Hit Error (right)")] - HitErrorRight, - - [Description("Hit Error (left+right)")] - HitErrorBoth, - - [Description("Hit Error (bottom)")] - HitErrorBottom, - - [Description("Colour (left)")] - ColourLeft, - - [Description("Colour (right)")] - ColourRight, - - [Description("Colour (left+right)")] - ColourBoth, - - [Description("Colour (bottom)")] - ColourBottom, - } -} diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index fd401119ff..ac94c39bd2 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.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.Framework.Bindables; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; @@ -12,19 +13,25 @@ namespace osu.Game.Configuration /// public class SessionStatics : InMemoryConfigManager { - protected override void InitialiseDefaults() + protected override void InitialiseDefaults() => ResetValues(); + + public void ResetValues() { - Set(Static.LoginOverlayDisplayed, false); - Set(Static.MutedAudioNotificationShownOnce, false); - Set(Static.LastHoverSoundPlaybackTime, (double?)null); - Set(Static.SeasonalBackgrounds, null); + ensureDefault(SetDefault(Static.LoginOverlayDisplayed, false)); + ensureDefault(SetDefault(Static.MutedAudioNotificationShownOnce, false)); + ensureDefault(SetDefault(Static.LowBatteryNotificationShownOnce, false)); + ensureDefault(SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null)); + ensureDefault(SetDefault(Static.SeasonalBackgrounds, null)); } + + private void ensureDefault(Bindable bindable) => bindable.SetDefault(); } public enum Static { LoginOverlayDisplayed, MutedAudioNotificationShownOnce, + LowBatteryNotificationShownOnce, /// /// Info about seasonal backgrounds available fetched from API - see . diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 70d67aaaa0..3e50613093 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -1,13 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using JetBrains.Annotations; using osu.Framework.Bindables; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Overlays.Settings; namespace osu.Game.Configuration @@ -22,15 +26,23 @@ namespace osu.Game.Configuration /// [MeansImplicitUse] [AttributeUsage(AttributeTargets.Property)] - public class SettingSourceAttribute : Attribute + public class SettingSourceAttribute : Attribute, IComparable { - public string Label { get; } + public LocalisableString Label { get; } public string Description { get; } public int? OrderPosition { get; } - public SettingSourceAttribute(string label, string description = null) + /// + /// The type of the settings control which handles this setting source. + /// + /// + /// Must be a type deriving with a public parameterless constructor. + /// + public Type? SettingControlType { get; set; } + + public SettingSourceAttribute(string? label, string? description = null) { Label = label ?? string.Empty; Description = description ?? string.Empty; @@ -41,6 +53,21 @@ namespace osu.Game.Configuration { OrderPosition = orderPosition; } + + public int CompareTo(SettingSourceAttribute other) + { + if (OrderPosition == other.OrderPosition) + return 0; + + // unordered items come last (are greater than any ordered items). + if (OrderPosition == null) + return 1; + if (other.OrderPosition == null) + return -1; + + // ordered items are sorted by the order value. + return OrderPosition.Value.CompareTo(other.OrderPosition); + } } public static class SettingSourceExtensions @@ -51,6 +78,22 @@ namespace osu.Game.Configuration { object value = property.GetValue(obj); + if (attr.SettingControlType != null) + { + var controlType = attr.SettingControlType; + if (controlType.EnumerateBaseTypes().All(t => !t.IsGenericType || t.GetGenericTypeDefinition() != typeof(SettingsItem<>))) + throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})"); + + var control = (Drawable)Activator.CreateInstance(controlType); + controlType.GetProperty(nameof(SettingsItem.LabelText))?.SetValue(control, attr.Label); + controlType.GetProperty(nameof(SettingsItem.TooltipText))?.SetValue(control, attr.Description); + controlType.GetProperty(nameof(SettingsItem.Current))?.SetValue(control, value); + + yield return control; + + continue; + } + switch (value) { case BindableNumber bNumber: @@ -136,14 +179,9 @@ namespace osu.Game.Configuration } } - public static IEnumerable<(SettingSourceAttribute, PropertyInfo)> GetOrderedSettingsSourceProperties(this object obj) - { - var original = obj.GetSettingsSourceProperties(); - - var orderedRelative = original.Where(attr => attr.Item1.OrderPosition != null).OrderBy(attr => attr.Item1.OrderPosition); - var unordered = original.Except(orderedRelative); - - return orderedRelative.Concat(unordered); - } + public static ICollection<(SettingSourceAttribute, PropertyInfo)> GetOrderedSettingsSourceProperties(this object obj) + => obj.GetSettingsSourceProperties() + .OrderBy(attr => attr.Item1) + .ToArray(); } } diff --git a/osu.Game/Configuration/SettingsStore.cs b/osu.Game/Configuration/SettingsStore.cs index f8c9bdeaf8..86e84b0732 100644 --- a/osu.Game/Configuration/SettingsStore.cs +++ b/osu.Game/Configuration/SettingsStore.cs @@ -22,7 +22,6 @@ namespace osu.Game.Configuration /// /// The ruleset's internal ID. /// An optional variant. - /// public List Query(int? rulesetId = null, int? variant = null) => ContextFactory.Get().DatabasedSetting.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList(); diff --git a/osu.Game/Configuration/StorageConfigManager.cs b/osu.Game/Configuration/StorageConfigManager.cs index 929f8f22ad..90ea42b638 100644 --- a/osu.Game/Configuration/StorageConfigManager.cs +++ b/osu.Game/Configuration/StorageConfigManager.cs @@ -19,7 +19,7 @@ namespace osu.Game.Configuration { base.InitialiseDefaults(); - Set(StorageConfig.FullPath, string.Empty); + SetDefault(StorageConfig.FullPath, string.Empty); } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 03b8db2cb8..8efd451857 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -10,7 +10,6 @@ using System.Threading.Tasks; using Humanizer; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -using osu.Framework; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; @@ -22,7 +21,6 @@ using osu.Game.IO.Archives; using osu.Game.IPC; using osu.Game.Overlays.Notifications; using SharpCompress.Archives.Zip; -using FileInfo = osu.Game.IO.FileInfo; namespace osu.Game.Database { @@ -82,8 +80,6 @@ namespace osu.Game.Database public virtual IEnumerable HandledExtensions => new[] { ".zip" }; - public virtual bool SupportsImportFromStable => RuntimeInfo.IsDesktop; - protected readonly FileStore Files; protected readonly IDatabaseContextFactory ContextFactory; @@ -141,6 +137,13 @@ namespace osu.Game.Database protected async Task> Import(ProgressNotification notification, params ImportTask[] tasks) { + if (tasks.Length == 0) + { + notification.CompletionText = $"No {HumanisedModelName}s were found to import!"; + notification.State = ProgressNotificationState.Completed; + return Enumerable.Empty(); + } + notification.Progress = 0; notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising..."; @@ -150,33 +153,44 @@ namespace osu.Game.Database bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size; - await Task.WhenAll(tasks.Select(async task => + try { - notification.CancellationToken.ThrowIfCancellationRequested(); - - try + await Task.WhenAll(tasks.Select(async task => { - var model = await Import(task, isLowPriorityImport, notification.CancellationToken); + notification.CancellationToken.ThrowIfCancellationRequested(); - lock (imported) + try { - if (model != null) - imported.Add(model); - current++; + var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false); - notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; - notification.Progress = (float)current / tasks.Length; + lock (imported) + { + if (model != null) + imported.Add(model); + current++; + + notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; + notification.Progress = (float)current / tasks.Length; + } } - } - catch (TaskCanceledException) + catch (TaskCanceledException) + { + throw; + } + catch (Exception e) + { + Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); + } + })).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (imported.Count == 0) { - throw; + notification.State = ProgressNotificationState.Cancelled; + return imported; } - catch (Exception e) - { - Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); - } - })); + } if (imported.Count == 0) { @@ -219,7 +233,7 @@ namespace osu.Game.Database TModel import; using (ArchiveReader reader = task.GetReader()) - import = await Import(reader, lowPriority, cancellationToken); + import = await Import(reader, lowPriority, cancellationToken).ConfigureAwait(false); // We may or may not want to delete the file depending on where it is stored. // e.g. reconstructing/repairing database with items from default storage. @@ -351,7 +365,7 @@ namespace osu.Game.Database item.Files = archive != null ? createFileInfos(archive, Files) : new List(); item.Hash = ComputeHash(item, archive); - await Populate(item, archive, cancellationToken); + await Populate(item, archive, cancellationToken).ConfigureAwait(false); using (var write = ContextFactory.GetForWrite()) // used to share a context for full import. keep in mind this will block all writes. { @@ -403,7 +417,7 @@ namespace osu.Game.Database flushEvents(true); return item; - }, cancellationToken, TaskCreationOptions.HideScheduler, lowPriority ? import_scheduler_low_priority : import_scheduler).Unwrap(); + }, cancellationToken, TaskCreationOptions.HideScheduler, lowPriority ? import_scheduler_low_priority : import_scheduler).Unwrap().ConfigureAwait(false); /// /// Exports an item to a legacy (.zip based) package. @@ -416,15 +430,25 @@ namespace osu.Game.Database if (retrievedItem == null) throw new ArgumentException("Specified model could not be found", nameof(item)); + using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create)) + ExportModelTo(retrievedItem, outputStream); + + exportStorage.OpenInNativeExplorer(); + } + + /// + /// Exports an item to the given output stream. + /// + /// The item to export. + /// The output stream to export to. + protected virtual void ExportModelTo(TModel model, Stream outputStream) + { using (var archive = ZipArchive.Create()) { - foreach (var file in retrievedItem.Files) + foreach (var file in model.Files) archive.AddEntry(file.Filename, Files.Storage.GetStream(file.FileInfo.StoragePath)); - using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create)) - archive.SaveTo(outputStream); - - exportStorage.OpenInNativeExplorer(); + archive.SaveTo(outputStream); } } @@ -445,7 +469,7 @@ namespace osu.Game.Database } /// - /// Delete new file. + /// Delete an existing file. /// /// The item to operate on. /// The existing file to be deleted. @@ -614,7 +638,7 @@ namespace osu.Game.Database } /// - /// Create all required s for the provided archive, adding them to the global file store. + /// Create all required s for the provided archive, adding them to the global file store. /// private List createFileInfos(ArchiveReader reader, FileStore files) { @@ -642,16 +666,6 @@ namespace osu.Game.Database #region osu-stable import - /// - /// Set a storage with access to an osu-stable install for import purposes. - /// - public Func GetStableStorage { private get; set; } - - /// - /// Denotes whether an osu-stable installation is present to perform automated imports from. - /// - public bool StableInstallationAvailable => GetStableStorage?.Invoke() != null; - /// /// The relative path from osu-stable's data directory to import items from. /// @@ -673,26 +687,20 @@ namespace osu.Game.Database /// /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. /// - public Task ImportFromStableAsync() + public Task ImportFromStableAsync(StableStorage stableStorage) { - var stableStorage = GetStableStorage?.Invoke(); - - if (stableStorage == null) - { - Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error); - return Task.CompletedTask; - } - var storage = PrepareStableStorage(stableStorage); + // Handle situations like when the user does not have a Skins folder. if (!storage.ExistsDirectory(ImportFromStablePath)) { - // This handles situations like when the user does not have a Skins folder - Logger.Log($"No {ImportFromStablePath} folder available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); + string fullPath = storage.GetFullPath(ImportFromStablePath); + + Logger.Log($"Folder \"{fullPath}\" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error); return Task.CompletedTask; } - return Task.Run(async () => await Import(GetStableImportPaths(storage).ToArray())); + return Task.Run(async () => await Import(GetStableImportPaths(storage).ToArray()).ConfigureAwait(false)); } /// diff --git a/osu.Game/Database/DatabaseWriteUsage.cs b/osu.Game/Database/DatabaseWriteUsage.cs index ddafd77066..84c39e3532 100644 --- a/osu.Game/Database/DatabaseWriteUsage.cs +++ b/osu.Game/Database/DatabaseWriteUsage.cs @@ -54,10 +54,5 @@ namespace osu.Game.Database Dispose(true); GC.SuppressFinalize(this); } - - ~DatabaseWriteUsage() - { - Dispose(false); - } } } diff --git a/osu.Game/Database/DownloadableArchiveModelManager.cs b/osu.Game/Database/DownloadableArchiveModelManager.cs index 50b022f9ff..da3144e8d0 100644 --- a/osu.Game/Database/DownloadableArchiveModelManager.cs +++ b/osu.Game/Database/DownloadableArchiveModelManager.cs @@ -82,7 +82,7 @@ namespace osu.Game.Database Task.Factory.StartNew(async () => { // This gets scheduled back to the update thread, but we want the import to run in the background. - var imported = await Import(notification, new ImportTask(filename)); + var imported = await Import(notification, new ImportTask(filename)).ConfigureAwait(false); // for now a failed import will be marked as a failed download for simplicity. if (!imported.Any()) diff --git a/osu.Game/Database/MemoryCachingComponent.cs b/osu.Game/Database/MemoryCachingComponent.cs index d913e66428..a1a1279d71 100644 --- a/osu.Game/Database/MemoryCachingComponent.cs +++ b/osu.Game/Database/MemoryCachingComponent.cs @@ -29,7 +29,7 @@ namespace osu.Game.Database if (CheckExists(lookup, out TValue performance)) return performance; - var computed = await ComputeValueAsync(lookup, token); + var computed = await ComputeValueAsync(lookup, token).ConfigureAwait(false); if (computed != null || CacheNullValues) cache[lookup] = computed; diff --git a/osu.Game/Database/StableImportManager.cs b/osu.Game/Database/StableImportManager.cs new file mode 100644 index 0000000000..63a6db35c0 --- /dev/null +++ b/osu.Game/Database/StableImportManager.cs @@ -0,0 +1,96 @@ +// 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.Threading.Tasks; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.IO; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings.Sections.Maintenance; +using osu.Game.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Database +{ + public class StableImportManager : Component + { + [Resolved] + private SkinManager skins { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + [Resolved] + private ScoreManager scores { get; set; } + + [Resolved] + private CollectionManager collections { get; set; } + + [Resolved] + private OsuGame game { get; set; } + + [Resolved] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private DesktopGameHost desktopGameHost { get; set; } + + private StableStorage cachedStorage; + + public bool SupportsImportFromStable => RuntimeInfo.IsDesktop; + + public async Task ImportFromStableAsync(StableContent content) + { + var stableStorage = await getStableStorage().ConfigureAwait(false); + var importTasks = new List(); + + Task beatmapImportTask = Task.CompletedTask; + if (content.HasFlagFast(StableContent.Beatmaps)) + importTasks.Add(beatmapImportTask = beatmaps.ImportFromStableAsync(stableStorage)); + + if (content.HasFlagFast(StableContent.Skins)) + importTasks.Add(skins.ImportFromStableAsync(stableStorage)); + + if (content.HasFlagFast(StableContent.Collections)) + importTasks.Add(beatmapImportTask.ContinueWith(_ => collections.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); + + if (content.HasFlagFast(StableContent.Scores)) + importTasks.Add(beatmapImportTask.ContinueWith(_ => scores.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion)); + + await Task.WhenAll(importTasks.ToArray()).ConfigureAwait(false); + } + + private async Task getStableStorage() + { + if (cachedStorage != null) + return cachedStorage; + + var stableStorage = game.GetStorageForStableInstall(); + if (stableStorage != null) + return cachedStorage = stableStorage; + + var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Schedule(() => dialogOverlay.Push(new StableDirectoryLocationDialog(taskCompletionSource))); + var stablePath = await taskCompletionSource.Task.ConfigureAwait(false); + + return cachedStorage = new StableStorage(stablePath, desktopGameHost); + } + } + + [Flags] + public enum StableContent + { + Beatmaps = 1 << 0, + Scores = 1 << 1, + Skins = 1 << 2, + Collections = 1 << 3, + All = Beatmaps | Scores | Skins | Collections + } +} diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index 568726199c..19cc211709 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -28,7 +28,7 @@ namespace osu.Game.Database public Task GetUserAsync(int userId, CancellationToken token = default) => GetAsync(userId, token); protected override async Task ComputeValueAsync(int lookup, CancellationToken token = default) - => await queryUser(lookup); + => await queryUser(lookup).ConfigureAwait(false); private readonly Queue<(int id, TaskCompletionSource)> pendingUserTasks = new Queue<(int, TaskCompletionSource)>(); private Task pendingRequestTask; diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index 1790eb608e..2ac6e6ff22 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -2,13 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Threading; +using osu.Game.Screens.Play.HUD; +using osuTK; namespace osu.Game.Extensions { public static class DrawableExtensions { + public const double REPEAT_INTERVAL = 70; + public const double INITIAL_DELAY = 250; + /// /// Helper method that is used while doesn't support repetitions of . /// Simulates repetitions by continually invoking a delegate according to the default key repeat rate. @@ -19,14 +26,42 @@ namespace osu.Game.Extensions /// The which is handling the repeat. /// The to schedule repetitions on. /// The to be invoked once immediately and with every repetition. + /// The delay imposed on the first repeat. Defaults to . /// A which can be cancelled to stop the repeat events from firing. - public static ScheduledDelegate BeginKeyRepeat(this IKeyBindingHandler handler, Scheduler scheduler, Action action) + public static ScheduledDelegate BeginKeyRepeat(this IKeyBindingHandler handler, Scheduler scheduler, Action action, double initialRepeatDelay = INITIAL_DELAY) { action(); - ScheduledDelegate repeatDelegate = new ScheduledDelegate(action, handler.Time.Current + 250, 70); + ScheduledDelegate repeatDelegate = new ScheduledDelegate(action, handler.Time.Current + initialRepeatDelay, REPEAT_INTERVAL); scheduler.Add(repeatDelegate); return repeatDelegate; } + + /// + /// Accepts a delta vector in screen-space coordinates and converts it to one which can be applied to this drawable's position. + /// + /// The drawable. + /// A delta in screen-space coordinates. + /// The delta vector in Parent's coordinates. + public static Vector2 ScreenSpaceDeltaToParentSpace(this Drawable drawable, Vector2 delta) => + drawable.Parent.ToLocalSpace(drawable.Parent.ToScreenSpace(Vector2.Zero) + delta); + + public static SkinnableInfo CreateSkinnableInfo(this Drawable component) => new SkinnableInfo(component); + + public static void ApplySkinnableInfo(this Drawable component, SkinnableInfo info) + { + // todo: can probably make this better via deserialisation directly using a common interface. + component.Position = info.Position; + component.Rotation = info.Rotation; + component.Scale = info.Scale; + component.Anchor = info.Anchor; + component.Origin = info.Origin; + + if (component is Container container) + { + foreach (var child in info.Children) + container.Add(child.CreateInstance()); + } + } } } diff --git a/osu.Game/Extensions/TaskExtensions.cs b/osu.Game/Extensions/TaskExtensions.cs index 76a76c0c52..17f1a491f8 100644 --- a/osu.Game/Extensions/TaskExtensions.cs +++ b/osu.Game/Extensions/TaskExtensions.cs @@ -6,6 +6,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using osu.Framework.Extensions.ObjectExtensions; namespace osu.Game.Extensions { @@ -50,7 +51,7 @@ namespace osu.Game.Extensions } else if (continuationTask.IsFaulted) { - tcs.TrySetException(continuationTask.Exception); + tcs.TrySetException(continuationTask.Exception.AsNonNull()); } else { diff --git a/osu.Game/Extensions/TypeExtensions.cs b/osu.Game/Extensions/TypeExtensions.cs new file mode 100644 index 0000000000..2e93c81758 --- /dev/null +++ b/osu.Game/Extensions/TypeExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; + +namespace osu.Game.Extensions +{ + internal static class TypeExtensions + { + /// + /// Returns 's + /// with the assembly version, culture and public key token values removed. + /// + /// + /// This method is usually used in extensibility scenarios (i.e. for custom rulesets or skins) + /// when a version-agnostic identifier associated with a C# class - potentially originating from + /// an external assembly - is needed. + /// Leaving only the type and assembly names in such a scenario allows to preserve compatibility + /// across assembly versions. + /// + internal static string GetInvariantInstantiationInfo(this Type type) + { + string assemblyQualifiedName = type.AssemblyQualifiedName; + if (assemblyQualifiedName == null) + throw new ArgumentException($"{type}'s assembly-qualified name is null. Ensure that it is a concrete type and not a generic type parameter.", nameof(type)); + + return string.Join(',', assemblyQualifiedName.Split(',').Take(2)); + } + } +} diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 0e9382279a..67cee883c8 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -346,7 +346,6 @@ namespace osu.Game.Graphics.Backgrounds /// such that the smaller triangles appear on top. /// /// - /// public int CompareTo(TriangleParticle other) => other.Scale.CompareTo(Scale); } } diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index 914c8ff78d..5ff2fdf6b2 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -7,7 +7,10 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using System.Collections.Generic; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; using osu.Game.Users; namespace osu.Game.Graphics.Containers @@ -40,7 +43,7 @@ namespace osu.Game.Graphics.Containers AddText(text[previousLinkEnd..link.Index]); string displayText = text.Substring(link.Index, link.Length); - string linkArgument = link.Argument ?? link.Url; + string linkArgument = link.Argument; string tooltip = displayText == link.Url ? null : link.Url; AddLink(displayText, link.Action, linkArgument, tooltip); @@ -54,12 +57,20 @@ namespace osu.Game.Graphics.Containers createLink(AddText(text, creationParameters), new LinkDetails(LinkAction.External, url), url); public void AddLink(string text, Action action, string tooltipText = null, Action creationParameters = null) - => createLink(AddText(text, creationParameters), new LinkDetails(LinkAction.Custom, null), tooltipText, action); + => createLink(AddText(text, creationParameters), new LinkDetails(LinkAction.Custom, string.Empty), tooltipText, action); public void AddLink(string text, LinkAction action, string argument, string tooltipText = null, Action creationParameters = null) => createLink(AddText(text, creationParameters), new LinkDetails(action, argument), tooltipText); - public void AddLink(IEnumerable text, LinkAction action = LinkAction.External, string linkArgument = null, string tooltipText = null) + public void AddLink(LocalisableString text, LinkAction action, string argument, string tooltipText = null, Action creationParameters = null) + { + var spriteText = new OsuSpriteText { Text = text }; + + AddText(spriteText, creationParameters); + createLink(spriteText.Yield(), new LinkDetails(action, argument), tooltipText); + } + + public void AddLink(IEnumerable text, LinkAction action, string linkArgument, string tooltipText = null) { foreach (var t in text) AddArbitraryDrawable(t); diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs new file mode 100644 index 0000000000..6facf4e26c --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig; +using Markdig.Extensions.AutoIdentifiers; +using Markdig.Extensions.Tables; +using Markdig.Extensions.Yaml; +using Markdig.Syntax; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownContainer : MarkdownContainer + { + public OsuMarkdownContainer() + { + LineSpacing = 21; + } + + protected override void AddMarkdownComponent(IMarkdownObject markdownObject, FillFlowContainer container, int level) + { + switch (markdownObject) + { + case YamlFrontMatterBlock _: + // Don't parse YAML Frontmatter + break; + + case ListItemBlock listItemBlock: + var isOrdered = ((ListBlock)listItemBlock.Parent).IsOrdered; + var childContainer = CreateListItem(listItemBlock, level, isOrdered); + container.Add(childContainer); + foreach (var single in listItemBlock) + base.AddMarkdownComponent(single, childContainer.Content, level); + break; + + default: + base.AddMarkdownComponent(markdownObject, container, level); + break; + } + } + + public override SpriteText CreateSpriteText() => new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14), + }; + + public override MarkdownTextFlowContainer CreateTextFlow() => new OsuMarkdownTextFlowContainer(); + + protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new OsuMarkdownHeading(headingBlock); + + protected override MarkdownFencedCodeBlock CreateFencedCodeBlock(FencedCodeBlock fencedCodeBlock) => new OsuMarkdownFencedCodeBlock(fencedCodeBlock); + + protected override MarkdownSeparator CreateSeparator(ThematicBreakBlock thematicBlock) => new OsuMarkdownSeparator(); + + protected override MarkdownQuoteBlock CreateQuoteBlock(QuoteBlock quoteBlock) => new OsuMarkdownQuoteBlock(quoteBlock); + + protected override MarkdownTable CreateTable(Table table) => new OsuMarkdownTable(table); + + protected override MarkdownList CreateList(ListBlock listBlock) => new MarkdownList + { + Padding = new MarginPadding(0) + }; + + protected virtual OsuMarkdownListItem CreateListItem(ListItemBlock listItemBlock, int level, bool isOrdered) + { + if (isOrdered) + return new OsuMarkdownOrderedListItem(listItemBlock.Order); + + return new OsuMarkdownUnorderedListItem(level); + } + + protected override MarkdownPipeline CreateBuilder() + => new MarkdownPipelineBuilder().UseAutoIdentifiers(AutoIdentifierOptions.GitHub) + .UseEmojiAndSmiley() + .UseYamlFrontMatter() + .UseAdvancedExtensions().Build(); + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownFencedCodeBlock.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownFencedCodeBlock.cs new file mode 100644 index 0000000000..0d67849060 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownFencedCodeBlock.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Syntax; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Shapes; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownFencedCodeBlock : MarkdownFencedCodeBlock + { + // TODO : change to monospace font for this component + public OsuMarkdownFencedCodeBlock(FencedCodeBlock fencedCodeBlock) + : base(fencedCodeBlock) + { + } + + protected override Drawable CreateBackground() => new CodeBlockBackground(); + + public override MarkdownTextFlowContainer CreateTextFlow() => new CodeBlockTextFlowContainer(); + + private class CodeBlockBackground : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.Both; + Colour = colourProvider.Background6; + } + } + + private class CodeBlockTextFlowContainer : OsuMarkdownTextFlowContainer + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Light1; + Margin = new MarginPadding(10); + } + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs new file mode 100644 index 0000000000..40eb4cad15 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs @@ -0,0 +1,75 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Syntax; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownHeading : MarkdownHeading + { + private readonly int level; + + public OsuMarkdownHeading(HeadingBlock headingBlock) + : base(headingBlock) + { + level = headingBlock.Level; + } + + public override MarkdownTextFlowContainer CreateTextFlow() => new HeadingTextFlowContainer + { + Weight = GetFontWeightByLevel(level), + }; + + protected override float GetFontSizeByLevel(int level) + { + // Reference for this font size + // https://github.com/ppy/osu-web/blob/376cac43a051b9c85ce95e2c446099be187b3e45/resources/assets/less/bem/osu-md.less#L9 + // https://github.com/ppy/osu-web/blob/376cac43a051b9c85ce95e2c446099be187b3e45/resources/assets/less/variables.less#L161 + const float base_font_size = 14; + + switch (level) + { + case 1: + return 30 / base_font_size; + + case 2: + return 26 / base_font_size; + + case 3: + return 20 / base_font_size; + + case 4: + return 18 / base_font_size; + + case 5: + return 16 / base_font_size; + + default: + return 1; + } + } + + protected virtual FontWeight GetFontWeightByLevel(int level) + { + switch (level) + { + case 1: + case 2: + return FontWeight.SemiBold; + + default: + return FontWeight.Bold; + } + } + + private class HeadingTextFlowContainer : OsuMarkdownTextFlowContainer + { + public FontWeight Weight { get; set; } + + protected override SpriteText CreateSpriteText() => base.CreateSpriteText().With(t => t.Font = t.Font.With(weight: Weight)); + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs new file mode 100644 index 0000000000..f91a0e40e3 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs @@ -0,0 +1,63 @@ +// 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 Markdig.Syntax.Inlines; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Game.Online.Chat; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownLinkText : MarkdownLinkText + { + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + + private readonly string text; + private readonly string title; + + public OsuMarkdownLinkText(string text, LinkInline linkInline) + : base(text, linkInline) + { + this.text = text; + title = linkInline.Title; + } + + [BackgroundDependencyLoader] + private void load() + { + var textDrawable = CreateSpriteText().With(t => t.Text = text); + + InternalChildren = new Drawable[] + { + textDrawable, + new OsuMarkdownLinkCompiler(new[] { textDrawable }) + { + RelativeSizeAxes = Axes.Both, + Action = OnLinkPressed, + TooltipText = title ?? Url, + } + }; + } + + protected override void OnLinkPressed() => game?.HandleLink(Url); + + private class OsuMarkdownLinkCompiler : DrawableLinkCompiler + { + public OsuMarkdownLinkCompiler(IEnumerable parts) + : base(parts) + { + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Light2; + HoverColour = colourProvider.Light1; + } + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownListItem.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownListItem.cs new file mode 100644 index 0000000000..8c4c3e1da2 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownListItem.cs @@ -0,0 +1,44 @@ +// 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.Containers.Markdown; +using osu.Framework.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public abstract class OsuMarkdownListItem : CompositeDrawable + { + [Resolved] + private IMarkdownTextComponent parentTextComponent { get; set; } + + public FillFlowContainer Content { get; private set; } + + protected OsuMarkdownListItem() + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + CreateMarker(), + Content = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10, 10), + } + }; + } + + protected virtual SpriteText CreateMarker() => parentTextComponent.CreateSpriteText(); + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownOrderedListItem.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownOrderedListItem.cs new file mode 100644 index 0000000000..8fedb189b2 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownOrderedListItem.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownOrderedListItem : OsuMarkdownListItem + { + private const float left_padding = 30; + + private readonly int order; + + public OsuMarkdownOrderedListItem(int order) + { + this.order = order; + Padding = new MarginPadding { Left = left_padding }; + } + + protected override SpriteText CreateMarker() => base.CreateMarker().With(t => + { + t.X = -left_padding; + t.Text = $"{order}."; + }); + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownQuoteBlock.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownQuoteBlock.cs new file mode 100644 index 0000000000..9935c81537 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownQuoteBlock.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Syntax; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Shapes; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownQuoteBlock : MarkdownQuoteBlock + { + public OsuMarkdownQuoteBlock(QuoteBlock quoteBlock) + : base(quoteBlock) + { + } + + protected override Drawable CreateBackground() => new QuoteBackground(); + + public override MarkdownTextFlowContainer CreateTextFlow() + { + return base.CreateTextFlow().With(f => f.Margin = new MarginPadding + { + Vertical = 10, + Horizontal = 20, + }); + } + + private class QuoteBackground : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + RelativeSizeAxes = Axes.Y; + Width = 2; + Colour = colourProvider.Content2; + } + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownSeparator.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownSeparator.cs new file mode 100644 index 0000000000..28a87c9f21 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownSeparator.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Shapes; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownSeparator : MarkdownSeparator + { + protected override Drawable CreateSeparator() => new Separator(); + + private class Separator : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + Height = 1; + Colour = colourProvider.Background3; + } + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTable.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTable.cs new file mode 100644 index 0000000000..e0a1ab1220 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTable.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 Markdig.Extensions.Tables; +using osu.Framework.Graphics.Containers.Markdown; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownTable : MarkdownTable + { + public OsuMarkdownTable(Table table) + : base(table) + { + } + + protected override MarkdownTableCell CreateTableCell(TableCell cell, TableColumnDefinition definition, bool isHeading) => new OsuMarkdownTableCell(cell, definition, isHeading); + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTableCell.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTableCell.cs new file mode 100644 index 0000000000..ac7d07e283 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTableCell.cs @@ -0,0 +1,77 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Extensions.Tables; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownTableCell : MarkdownTableCell + { + private readonly bool isHeading; + + public OsuMarkdownTableCell(TableCell cell, TableColumnDefinition definition, bool isHeading) + : base(cell, definition) + { + this.isHeading = isHeading; + Masking = false; + BorderThickness = 0; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(CreateBorder(isHeading)); + } + + public override MarkdownTextFlowContainer CreateTextFlow() => new TableCellTextFlowContainer + { + Weight = isHeading ? FontWeight.Bold : FontWeight.Regular, + Padding = new MarginPadding(10), + }; + + protected virtual Box CreateBorder(bool isHeading) + { + if (isHeading) + return new TableHeadBorder(); + + return new TableBodyBorder(); + } + + private class TableHeadBorder : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Background3; + RelativeSizeAxes = Axes.X; + Height = 2; + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + } + } + + private class TableBodyBorder : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Background4; + RelativeSizeAxes = Axes.X; + Height = 1; + } + } + + private class TableCellTextFlowContainer : OsuMarkdownTextFlowContainer + { + public FontWeight Weight { get; set; } + + protected override SpriteText CreateSpriteText() => base.CreateSpriteText().With(t => t.Font = t.Font.With(weight: Weight)); + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs new file mode 100644 index 0000000000..f3308019ce --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Syntax.Inlines; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownTextFlowContainer : MarkdownTextFlowContainer + { + protected override void AddLinkText(string text, LinkInline linkInline) + => AddDrawable(new OsuMarkdownLinkText(text, linkInline)); + + // TODO : Change font to monospace + protected override void AddCodeInLine(CodeInline codeInline) => AddDrawable(new OsuMarkdownInlineCode + { + Text = codeInline.Content + }); + + protected override SpriteText CreateEmphasisedSpriteText(bool bold, bool italic) + => CreateSpriteText().With(t => t.Font = t.Font.With(weight: bold ? FontWeight.Bold : FontWeight.Regular, italics: italic)); + + private class OsuMarkdownInlineCode : Container + { + [Resolved] + private IMarkdownTextComponent parentTextComponent { get; set; } + + public string Text; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AutoSizeAxes = Axes.Both; + CornerRadius = 4; + Masking = true; + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + parentTextComponent.CreateSpriteText().With(t => + { + t.Colour = colourProvider.Light1; + t.Text = Text; + t.Padding = new MarginPadding + { + Vertical = 1, + Horizontal = 4, + }; + }), + }; + } + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownUnorderedListItem.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownUnorderedListItem.cs new file mode 100644 index 0000000000..5d1e114781 --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownUnorderedListItem.cs @@ -0,0 +1,51 @@ +// 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.Graphics; +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownUnorderedListItem : OsuMarkdownListItem + { + private const float left_padding = 20; + + private readonly int level; + + public OsuMarkdownUnorderedListItem(int level) + { + this.level = level; + + Padding = new MarginPadding { Left = left_padding }; + } + + protected override SpriteText CreateMarker() => base.CreateMarker().With(t => + { + t.Text = GetTextMarker(level); + t.Font = t.Font.With(size: t.Font.Size / 2); + t.Origin = Anchor.Centre; + t.X = -left_padding / 2; + t.Y = t.Font.Size; + }); + + /// + /// Get text marker based on + /// + /// The markdown level of current list item. + /// The marker string of this list item + protected virtual string GetTextMarker(int level) + { + switch (level) + { + case 1: + return "●"; + + case 2: + return "○"; + + default: + return "■"; + } + } + } +} diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index fbf2ffd4bd..c0518247a9 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -23,10 +23,12 @@ namespace osu.Game.Graphics.Containers protected virtual string PopInSampleName => "UI/overlay-pop-in"; protected virtual string PopOutSampleName => "UI/overlay-pop-out"; + protected override bool BlockScrollInput => false; + protected override bool BlockNonPositionalInput => true; /// - /// Temporary to allow for overlays in the main screen content to not dim theirselves. + /// Temporary to allow for overlays in the main screen content to not dim themselves. /// Should be eventually replaced by dimming which is aware of the target dim container (traverse parent for certain interface type?). /// protected virtual bool DimMainContent => true; diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 8f07c3a656..2488fd14d0 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -36,6 +36,24 @@ namespace osu.Game.Graphics.Containers private BackgroundScreenStack backgroundStack; + private bool allowScaling = true; + + /// + /// Whether user scaling preferences should be applied. Enabled by default. + /// + public bool AllowScaling + { + get => allowScaling; + set + { + if (value == allowScaling) + return; + + allowScaling = value; + if (IsLoaded) updateSize(); + } + } + /// /// Create a new instance. /// @@ -139,7 +157,7 @@ namespace osu.Game.Graphics.Containers backgroundStack?.FadeOut(fade_time); } - bool scaling = targetMode == null || scalingMode.Value == targetMode; + bool scaling = AllowScaling && (targetMode == null || scalingMode.Value == targetMode); var targetSize = scaling ? new Vector2(sizeX.Value, sizeY.Value) : Vector2.One; var targetPosition = scaling ? new Vector2(posX.Value, posY.Value) * (Vector2.One - targetSize) : Vector2.Zero; diff --git a/osu.Game/Graphics/Containers/UserDimContainer.cs b/osu.Game/Graphics/Containers/UserDimContainer.cs index 39c1fdad52..4e555ac1eb 100644 --- a/osu.Game/Graphics/Containers/UserDimContainer.cs +++ b/osu.Game/Graphics/Containers/UserDimContainer.cs @@ -23,11 +23,6 @@ namespace osu.Game.Graphics.Containers protected const double BACKGROUND_FADE_DURATION = 800; - /// - /// Whether or not user-configured dim levels should be applied to the container. - /// - public readonly Bindable EnableUserDim = new Bindable(true); - /// /// Whether or not user-configured settings relating to brightness of elements should be ignored /// @@ -57,7 +52,7 @@ namespace osu.Game.Graphics.Containers private float breakLightening => LightenDuringBreaks.Value && IsBreakTime.Value ? BREAK_LIGHTEN_AMOUNT : 0; - protected float DimLevel => Math.Max(EnableUserDim.Value && !IgnoreUserSettings.Value ? (float)UserDimLevel.Value - breakLightening : 0, 0); + protected float DimLevel => Math.Max(!IgnoreUserSettings.Value ? (float)UserDimLevel.Value - breakLightening : 0, 0); protected override Container Content => dimContent; @@ -78,7 +73,6 @@ namespace osu.Game.Graphics.Containers LightenDuringBreaks = config.GetBindable(OsuSetting.LightenDuringBreaks); ShowStoryboard = config.GetBindable(OsuSetting.ShowStoryboard); - EnableUserDim.ValueChanged += _ => UpdateVisuals(); UserDimLevel.ValueChanged += _ => UpdateVisuals(); LightenDuringBreaks.ValueChanged += _ => UpdateVisuals(); IsBreakTime.ValueChanged += _ => UpdateVisuals(); diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 466d59b08b..15967c37c2 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -94,6 +94,18 @@ namespace osu.Game.Graphics } } + /// + /// Returns a foreground text colour that is supposed to contrast well with + /// the supplied . + /// + public static Color4 ForegroundTextColourFor(Color4 backgroundColour) + { + // formula taken from the RGB->YIQ conversions: https://en.wikipedia.org/wiki/YIQ + // brightness here is equivalent to the Y component in the above colour model, which is a rough estimate of lightness. + float brightness = 0.299f * backgroundColour.R + 0.587f * backgroundColour.G + 0.114f * backgroundColour.B; + return Gray(brightness > 0.5f ? 0.2f : 0.9f); + } + // See https://github.com/ppy/osu-web/blob/master/resources/assets/less/colors.less public readonly Color4 PurpleLighter = Color4Extensions.FromHex(@"eeeeff"); public readonly Color4 PurpleLight = Color4Extensions.FromHex(@"aa88ff"); @@ -186,6 +198,13 @@ namespace osu.Game.Graphics public readonly Color4 GrayE = Color4Extensions.FromHex(@"eee"); public readonly Color4 GrayF = Color4Extensions.FromHex(@"fff"); + // in latest editor design logic, need to figure out where these sit... + public readonly Color4 Lime1 = Color4Extensions.FromHex(@"b2ff66"); + public readonly Color4 Orange1 = Color4Extensions.FromHex(@"ffd966"); + + // Content Background + public readonly Color4 B5 = Color4Extensions.FromHex(@"222a28"); + public readonly Color4 RedLighter = Color4Extensions.FromHex(@"ffeded"); public readonly Color4 RedLight = Color4Extensions.FromHex(@"ed7787"); public readonly Color4 Red = Color4Extensions.FromHex(@"ed1121"); diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index f7914cbbca..fb7fe4947b 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -103,7 +103,7 @@ namespace osu.Game.Graphics } } - using (var image = await host.TakeScreenshotAsync()) + using (var image = await host.TakeScreenshotAsync().ConfigureAwait(false)) { if (Interlocked.Decrement(ref screenShotTasks) == 0 && cursorVisibility.Value == false) cursorVisibility.Value = true; @@ -116,13 +116,13 @@ namespace osu.Game.Graphics switch (screenshotFormat.Value) { case ScreenshotFormat.Png: - await image.SaveAsPngAsync(stream); + await image.SaveAsPngAsync(stream).ConfigureAwait(false); break; case ScreenshotFormat.Jpg: const int jpeg_quality = 92; - await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }); + await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false); break; default: diff --git a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs index 85df2d167f..fb273d7293 100644 --- a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs +++ b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osuTK; namespace osu.Game.Graphics.Sprites @@ -14,7 +15,7 @@ namespace osu.Game.Graphics.Sprites { private readonly OsuSpriteText spriteText, blurredText; - public string Text + public LocalisableString Text { get => spriteText.Text; set => blurredText.Text = spriteText.Text = value; diff --git a/osu.Game/Graphics/UserInterface/BarGraph.cs b/osu.Game/Graphics/UserInterface/BarGraph.cs index 953f3985f9..407bf6a923 100644 --- a/osu.Game/Graphics/UserInterface/BarGraph.cs +++ b/osu.Game/Graphics/UserInterface/BarGraph.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System.Collections.Generic; using System.Linq; +using osu.Framework.Extensions.EnumExtensions; namespace osu.Game.Graphics.UserInterface { @@ -24,11 +25,11 @@ namespace osu.Game.Graphics.UserInterface set { direction = value; - base.Direction = direction.HasFlag(BarDirection.Horizontal) ? FillDirection.Vertical : FillDirection.Horizontal; + base.Direction = direction.HasFlagFast(BarDirection.Horizontal) ? FillDirection.Vertical : FillDirection.Horizontal; foreach (var bar in Children) { - bar.Size = direction.HasFlag(BarDirection.Horizontal) ? new Vector2(1, 1.0f / Children.Count) : new Vector2(1.0f / Children.Count, 1); + bar.Size = direction.HasFlagFast(BarDirection.Horizontal) ? new Vector2(1, 1.0f / Children.Count) : new Vector2(1.0f / Children.Count, 1); bar.Direction = direction; } } @@ -56,14 +57,14 @@ namespace osu.Game.Graphics.UserInterface if (bar.Bar != null) { bar.Bar.Length = length; - bar.Bar.Size = direction.HasFlag(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1); + bar.Bar.Size = direction.HasFlagFast(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1); } else { Add(new Bar { RelativeSizeAxes = Axes.Both, - Size = direction.HasFlag(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1), + Size = direction.HasFlagFast(BarDirection.Horizontal) ? new Vector2(1, size) : new Vector2(size, 1), Length = length, Direction = Direction, }); diff --git a/osu.Game/Graphics/UserInterface/DangerousTriangleButton.cs b/osu.Game/Graphics/UserInterface/DangerousTriangleButton.cs new file mode 100644 index 0000000000..89a4c28c8c --- /dev/null +++ b/osu.Game/Graphics/UserInterface/DangerousTriangleButton.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.Framework.Allocation; + +namespace osu.Game.Graphics.UserInterface +{ + public class DangerousTriangleButton : TriangleButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundColour = colours.PinkDark; + Triangles.ColourDark = colours.PinkDarker; + Triangles.ColourLight = colours.Pink; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index 9b53ee7b2d..1047aa4255 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -15,6 +15,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.Effects; using osu.Game.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -180,9 +181,9 @@ namespace osu.Game.Graphics.UserInterface } } - private string text; + private LocalisableString text; - public string Text + public LocalisableString Text { get => text; set diff --git a/osu.Game/Graphics/UserInterface/DownloadButton.cs b/osu.Game/Graphics/UserInterface/DownloadButton.cs index 7a8db158c1..af270f30ae 100644 --- a/osu.Game/Graphics/UserInterface/DownloadButton.cs +++ b/osu.Game/Graphics/UserInterface/DownloadButton.cs @@ -27,7 +27,7 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load() { - AddInternal(checkmark = new SpriteIcon + Add(checkmark = new SpriteIcon { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index b499b26f38..8df2c1c2fd 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osuTK.Graphics; @@ -105,7 +106,7 @@ namespace osu.Game.Graphics.UserInterface protected class TextContainer : Container, IHasText { - public string Text + public LocalisableString Text { get => NormalText.Text; set diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index 00cf5798e7..f2e4c6d013 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs @@ -36,7 +36,7 @@ namespace osu.Game.Graphics.UserInterface public override void PlayHoverSample() { - sampleHover.Frequency.Value = 0.96 + RNG.NextDouble(0.08); + sampleHover.Frequency.Value = 0.98 + RNG.NextDouble(0.04); sampleHover.Play(); } } @@ -53,6 +53,9 @@ namespace osu.Game.Graphics.UserInterface Soft, [Description("-toolbar")] - Toolbar + Toolbar, + + [Description("-songselect")] + SongSelect } } diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index 9cf8f02024..a22c837080 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osuTK.Graphics; @@ -21,9 +22,9 @@ namespace osu.Game.Graphics.UserInterface /// public class OsuButton : Button { - public string Text + public LocalisableString Text { - get => SpriteText?.Text; + get => SpriteText?.Text ?? default; set { if (SpriteText != null) diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index cc76c12975..15fb00ccb0 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; @@ -168,7 +169,7 @@ namespace osu.Game.Graphics.UserInterface protected new class Content : FillFlowContainer, IHasText { - public string Text + public LocalisableString Text { get => Label.Text; set => Label.Text = value; @@ -215,7 +216,7 @@ namespace osu.Game.Graphics.UserInterface { protected readonly SpriteText Text; - protected override string Label + protected override LocalisableString Label { get => Text.Text; set => Text.Text = value; diff --git a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs index bdc95ee048..b66a4a58ce 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics.Sprites; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -35,7 +36,7 @@ namespace osu.Game.Graphics.UserInterface } } - public string Text + public LocalisableString Text { get => text.Text; set => text.Text = value; diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index d75e49a4ce..5747c846eb 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -4,11 +4,10 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Screens.Play.HUD; namespace osu.Game.Graphics.UserInterface { - public abstract class ScoreCounter : RollingCounter, IScoreCounter + public abstract class ScoreCounter : RollingCounter { protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; diff --git a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs index 924c7913f3..615895074c 100644 --- a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs +++ b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osuTK; using System.Collections.Generic; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -18,7 +19,7 @@ namespace osu.Game.Graphics.UserInterface { private const int duration = 200; - public string Text + public LocalisableString Text { get => text.Text; set => text.Text = value; diff --git a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs index acf4065f49..5c623150b7 100644 --- a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs @@ -9,28 +9,17 @@ namespace osu.Game.Graphics.UserInterface /// /// An with three possible states. /// - public class TernaryStateMenuItem : StatefulMenuItem + public abstract class TernaryStateMenuItem : StatefulMenuItem { /// /// Creates a new . /// /// The text to display. + /// A function to inform what the next state should be when this item is clicked. /// The type of action which this performs. /// A delegate to be invoked when this is pressed. - public TernaryStateMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null) - : this(text, getNextState, type, action) - { - } - - /// - /// Creates a new . - /// - /// The text to display. - /// A function that mutates a state to another state after this is pressed. - /// The type of action which this performs. - /// A delegate to be invoked when this is pressed. - protected TernaryStateMenuItem(string text, Func changeStateFunc, MenuItemType type, Action action) - : base(text, changeStateFunc, type, action) + protected TernaryStateMenuItem(string text, Func nextStateFunction, MenuItemType type = MenuItemType.Standard, Action action = null) + : base(text, nextStateFunction, type, action) { } @@ -47,23 +36,5 @@ namespace osu.Game.Graphics.UserInterface return null; } - - private static TernaryState getNextState(TernaryState state) - { - switch (state) - { - case TernaryState.False: - return TernaryState.True; - - case TernaryState.Indeterminate: - return TernaryState.True; - - case TernaryState.True: - return TernaryState.False; - - default: - throw new ArgumentOutOfRangeException(nameof(state), state, null); - } - } } } diff --git a/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs new file mode 100644 index 0000000000..46eda06294 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/TernaryStateRadioMenuItem.cs @@ -0,0 +1,26 @@ +// 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.Graphics.UserInterface +{ + /// + /// A ternary state menu item which will always set the item to true on click, even if already true. + /// + public class TernaryStateRadioMenuItem : TernaryStateMenuItem + { + /// + /// Creates a new . + /// + /// The text to display. + /// The type of action which this performs. + /// A delegate to be invoked when this is pressed. + public TernaryStateRadioMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null) + : base(text, getNextState, type, action) + { + } + + private static TernaryState getNextState(TernaryState state) => TernaryState.True; + } +} diff --git a/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs new file mode 100644 index 0000000000..ce951984fd --- /dev/null +++ b/osu.Game/Graphics/UserInterface/TernaryStateToggleMenuItem.cs @@ -0,0 +1,42 @@ +// 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.Graphics.UserInterface +{ + /// + /// A ternary state menu item which toggles the state of this item false if clicked when true. + /// + public class TernaryStateToggleMenuItem : TernaryStateMenuItem + { + /// + /// Creates a new . + /// + /// The text to display. + /// The type of action which this performs. + /// A delegate to be invoked when this is pressed. + public TernaryStateToggleMenuItem(string text, MenuItemType type = MenuItemType.Standard, Action action = null) + : base(text, getNextState, type, action) + { + } + + private static TernaryState getNextState(TernaryState state) + { + switch (state) + { + case TernaryState.False: + return TernaryState.True; + + case TernaryState.Indeterminate: + return TernaryState.True; + + case TernaryState.True: + return TernaryState.False; + + default: + throw new ArgumentOutOfRangeException(nameof(state), state, null); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/TriangleButton.cs b/osu.Game/Graphics/UserInterface/TriangleButton.cs index 5baf794227..003a81f562 100644 --- a/osu.Game/Graphics/UserInterface/TriangleButton.cs +++ b/osu.Game/Graphics/UserInterface/TriangleButton.cs @@ -27,7 +27,7 @@ namespace osu.Game.Graphics.UserInterface }); } - public virtual IEnumerable FilterTerms => new[] { Text }; + public virtual IEnumerable FilterTerms => new[] { Text.ToString() }; public bool MatchingFilter { diff --git a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index 120149d8c1..8f03c7073c 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -12,6 +12,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Beatmaps.ControlPoints; using osu.Framework.Audio.Track; using System; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; @@ -56,15 +57,15 @@ namespace osu.Game.Graphics.UserInterface set { base.Origin = value; - c1.Origin = c1.Anchor = value.HasFlag(Anchor.x2) ? Anchor.TopLeft : Anchor.TopRight; - c2.Origin = c2.Anchor = value.HasFlag(Anchor.x2) ? Anchor.TopRight : Anchor.TopLeft; + c1.Origin = c1.Anchor = value.HasFlagFast(Anchor.x2) ? Anchor.TopLeft : Anchor.TopRight; + c2.Origin = c2.Anchor = value.HasFlagFast(Anchor.x2) ? Anchor.TopRight : Anchor.TopLeft; - X = value.HasFlag(Anchor.x2) ? SIZE_RETRACTED.X * shear.X * 0.5f : 0; + X = value.HasFlagFast(Anchor.x2) ? SIZE_RETRACTED.X * shear.X * 0.5f : 0; Remove(c1); Remove(c2); - c1.Depth = value.HasFlag(Anchor.x2) ? 0 : 1; - c2.Depth = value.HasFlag(Anchor.x2) ? 1 : 0; + c1.Depth = value.HasFlagFast(Anchor.x2) ? 0 : 1; + c2.Depth = value.HasFlagFast(Anchor.x2) ? 1 : 0; Add(c1); Add(c2); } diff --git a/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs new file mode 100644 index 0000000000..01d91f7cfd --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.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.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + /// + /// A component which displays a colour along with related description text. + /// + public class ColourDisplay : CompositeDrawable, IHasCurrentValue + { + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + private Box fill; + private OsuSpriteText colourHexCode; + private OsuSpriteText colourName; + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private LocalisableString name; + + public LocalisableString ColourName + { + get => name; + set + { + if (name == value) + return; + + name = value; + + colourName.Text = name; + } + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Y; + Width = 100; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new CircularContainer + { + RelativeSizeAxes = Axes.X, + Height = 100, + Masking = true, + Children = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both + }, + colourHexCode = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 12) + } + } + }, + colourName = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + current.BindValueChanged(_ => updateColour(), true); + } + + private void updateColour() + { + fill.Colour = current.Value; + colourHexCode.Text = current.Value.ToHex(); + colourHexCode.Colour = OsuColour.ForegroundTextColourFor(current.Value); + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs new file mode 100644 index 0000000000..ba950048dc --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs @@ -0,0 +1,119 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + /// + /// A component which displays a collection of colours in individual s. + /// + public class ColourPalette : CompositeDrawable + { + public BindableList Colours { get; } = new BindableList(); + + private string colourNamePrefix = "Colour"; + + public string ColourNamePrefix + { + get => colourNamePrefix; + set + { + if (colourNamePrefix == value) + return; + + colourNamePrefix = value; + + if (IsLoaded) + reindexItems(); + } + } + + private FillFlowContainer palette; + private Container placeholder; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + palette = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Direction = FillDirection.Full + }, + placeholder = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Text = "(none)", + Font = OsuFont.Default.With(weight: FontWeight.Bold) + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Colours.BindCollectionChanged((_, __) => updatePalette(), true); + FinishTransforms(true); + } + + private const int fade_duration = 200; + + private void updatePalette() + { + palette.Clear(); + + if (Colours.Any()) + { + palette.FadeIn(fade_duration, Easing.OutQuint); + placeholder.FadeOut(fade_duration, Easing.OutQuint); + } + else + { + palette.FadeOut(fade_duration, Easing.OutQuint); + placeholder.FadeIn(fade_duration, Easing.OutQuint); + } + + foreach (var item in Colours) + { + palette.Add(new ColourDisplay + { + Current = { Value = item } + }); + } + + reindexItems(); + } + + private void reindexItems() + { + int index = 1; + + foreach (var colour in palette) + { + colour.ColourName = $"{colourNamePrefix} {index}"; + index += 1; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs new file mode 100644 index 0000000000..58443953bc --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs @@ -0,0 +1,26 @@ +// 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 osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class LabelledColourPalette : LabelledDrawable + { + public LabelledColourPalette() + : base(true) + { + } + + public BindableList Colours => Component.Colours; + + public string ColourNamePrefix + { + get => Component.ColourNamePrefix; + set => Component.ColourNamePrefix = value; + } + + protected override ColourPalette CreateComponent() => new ColourPalette(); + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs index 4aeda74be8..266eb11319 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 @@ -53,6 +54,14 @@ namespace osu.Game.Graphics.UserInterfaceV2 CornerRadius = CORNER_RADIUS, }; + public override bool AcceptsFocus => true; + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + GetContainingInputManager().ChangeFocus(Component); + } + protected override OsuTextBox CreateComponent() => CreateTextBox().With(t => { t.OnCommit += (sender, newText) => OnCommit?.Invoke(sender, newText); diff --git a/osu.Game/IO/Archives/ArchiveReader.cs b/osu.Game/IO/Archives/ArchiveReader.cs index f74574e60c..679ab40402 100644 --- a/osu.Game/IO/Archives/ArchiveReader.cs +++ b/osu.Game/IO/Archives/ArchiveReader.cs @@ -41,7 +41,7 @@ namespace osu.Game.IO.Archives return null; byte[] buffer = new byte[input.Length]; - await input.ReadAsync(buffer); + await input.ReadAsync(buffer).ConfigureAwait(false); return buffer; } } diff --git a/osu.Game/IO/IStorageResourceProvider.cs b/osu.Game/IO/IStorageResourceProvider.cs index cbd1039807..e4c97e18fa 100644 --- a/osu.Game/IO/IStorageResourceProvider.cs +++ b/osu.Game/IO/IStorageResourceProvider.cs @@ -19,6 +19,11 @@ namespace osu.Game.IO /// IResourceStore Files { get; } + /// + /// Access game-wide resources. + /// + IResourceStore Resources { get; } + /// /// Create a texture loader store based on an underlying data store. /// diff --git a/osu.Game/IO/Legacy/SerializationReader.cs b/osu.Game/IO/Legacy/SerializationReader.cs index 17cbd19838..00f90f78e3 100644 --- a/osu.Game/IO/Legacy/SerializationReader.cs +++ b/osu.Game/IO/Legacy/SerializationReader.cs @@ -38,6 +38,7 @@ namespace osu.Game.IO.Legacy /// Reads a string from the buffer. Overrides the base implementation so it can cope with nulls. public override string ReadString() { + // ReSharper disable once AssignNullToNotNullAttribute if (ReadByte() == 0) return null; return base.ReadString(); diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index 8097f61ea4..7df5d820ee 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -58,7 +58,7 @@ namespace osu.Game.IO /// public void ResetCustomStoragePath() { - storageConfig.Set(StorageConfig.FullPath, string.Empty); + storageConfig.SetValue(StorageConfig.FullPath, string.Empty); storageConfig.Save(); ChangeTargetStorage(defaultStorage); @@ -103,7 +103,7 @@ namespace osu.Game.IO public override void Migrate(Storage newStorage) { base.Migrate(newStorage); - storageConfig.Set(StorageConfig.FullPath, newStorage.GetFullPath(".")); + storageConfig.SetValue(StorageConfig.FullPath, newStorage.GetFullPath(".")); storageConfig.Save(); } } diff --git a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs index 50b28ea74b..174fbf9983 100644 --- a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs +++ b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using osu.Framework.Extensions.ObjectExtensions; namespace osu.Game.IO.Serialization.Converters { @@ -60,7 +61,7 @@ namespace osu.Game.IO.Serialization.Converters throw new JsonException("Expected $type token."); var typeName = lookupTable[(int)tok["$type"]]; - var instance = (T)Activator.CreateInstance(Type.GetType(typeName)); + var instance = (T)Activator.CreateInstance(Type.GetType(typeName).AsNonNull()); serializer.Populate(itemReader, instance); list.Add(instance); diff --git a/osu.Game/IO/Serialization/Converters/Vector2Converter.cs b/osu.Game/IO/Serialization/Converters/Vector2Converter.cs deleted file mode 100644 index 46447b607b..0000000000 --- a/osu.Game/IO/Serialization/Converters/Vector2Converter.cs +++ /dev/null @@ -1,34 +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 Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using osuTK; - -namespace osu.Game.IO.Serialization.Converters -{ - /// - /// A type of that serializes only the X and Y coordinates of a . - /// - public class Vector2Converter : JsonConverter - { - public override Vector2 ReadJson(JsonReader reader, Type objectType, Vector2 existingValue, bool hasExistingValue, JsonSerializer serializer) - { - var obj = JObject.Load(reader); - return new Vector2((float)obj["x"], (float)obj["y"]); - } - - public override void WriteJson(JsonWriter writer, Vector2 value, JsonSerializer serializer) - { - writer.WriteStartObject(); - - writer.WritePropertyName("x"); - writer.WriteValue(value.X); - writer.WritePropertyName("y"); - writer.WriteValue(value.Y); - - writer.WriteEndObject(); - } - } -} diff --git a/osu.Game/IO/Serialization/IJsonSerializable.cs b/osu.Game/IO/Serialization/IJsonSerializable.cs index ac95d47c4b..c8d5ce39a6 100644 --- a/osu.Game/IO/Serialization/IJsonSerializable.cs +++ b/osu.Game/IO/Serialization/IJsonSerializable.cs @@ -1,8 +1,9 @@ // 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 Newtonsoft.Json; -using osu.Game.IO.Serialization.Converters; +using osu.Framework.IO.Serialization; namespace osu.Game.IO.Serialization { @@ -21,14 +22,13 @@ namespace osu.Game.IO.Serialization /// /// Creates the default that should be used for all s. /// - /// public static JsonSerializerSettings CreateGlobalSettings() => new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore, Formatting = Formatting.Indented, ObjectCreationHandling = ObjectCreationHandling.Replace, DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, - Converters = new JsonConverter[] { new Vector2Converter() }, + Converters = new List { new Vector2Converter() }, ContractResolver = new KeyContractResolver() }; } diff --git a/osu.Game/IPC/ArchiveImportIPCChannel.cs b/osu.Game/IPC/ArchiveImportIPCChannel.cs index 029908ec9d..d9d0e4c0ea 100644 --- a/osu.Game/IPC/ArchiveImportIPCChannel.cs +++ b/osu.Game/IPC/ArchiveImportIPCChannel.cs @@ -33,12 +33,12 @@ namespace osu.Game.IPC if (importer == null) { // we want to contact a remote osu! to handle the import. - await SendMessageAsync(new ArchiveImportMessage { Path = path }); + await SendMessageAsync(new ArchiveImportMessage { Path = path }).ConfigureAwait(false); return; } if (importer.HandledExtensions.Contains(Path.GetExtension(path)?.ToLowerInvariant())) - await importer.Import(path); + await importer.Import(path).ConfigureAwait(false); } } diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index d12eaa10f6..23b09e8fb1 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -64,12 +64,21 @@ namespace osu.Game.Input.Bindings protected override void ReloadMappings() { + var defaults = DefaultKeyBindings.ToList(); + if (ruleset != null && !ruleset.ID.HasValue) // if the provided ruleset is not stored to the database, we have no way to retrieve custom bindings. // fallback to defaults instead. - KeyBindings = DefaultKeyBindings; + KeyBindings = defaults; else - KeyBindings = store.Query(ruleset?.ID, variant).ToList(); + { + KeyBindings = store.Query(ruleset?.ID, variant) + .OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.IntAction)) + // this ordering is important to ensure that we read entries from the database in the order + // enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise + // have been eaten by the music controller due to query order. + .ToList(); + } } } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 8ccdb9249e..c8227c0887 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -13,6 +13,7 @@ namespace osu.Game.Input.Bindings public class GlobalActionContainer : DatabasedKeyBindingContainer, IHandleGlobalKeyboardInput { private readonly Drawable handler; + private InputManager parentInputManager; public GlobalActionContainer(OsuGameBase game) : base(matchingMode: KeyCombinationMatchingMode.Modifiers) @@ -21,7 +22,18 @@ namespace osu.Game.Input.Bindings handler = game; } - public override IEnumerable DefaultKeyBindings => GlobalKeyBindings.Concat(InGameKeyBindings).Concat(AudioControlKeyBindings).Concat(EditorKeyBindings); + protected override void LoadComplete() + { + base.LoadComplete(); + + parentInputManager = GetContainingInputManager(); + } + + public override IEnumerable DefaultKeyBindings => GlobalKeyBindings + .Concat(EditorKeyBindings) + .Concat(InGameKeyBindings) + .Concat(SongSelectKeyBindings) + .Concat(AudioControlKeyBindings); public IEnumerable GlobalKeyBindings => new[] { @@ -36,6 +48,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleBeatmapListing), new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.S }, GlobalAction.ToggleSkinEditor), new KeyBinding(InputKey.Escape, GlobalAction.Back), new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back), @@ -58,6 +71,9 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.F2 }, GlobalAction.EditorDesignMode), new KeyBinding(new[] { InputKey.F3 }, GlobalAction.EditorTimingMode), new KeyBinding(new[] { InputKey.F4 }, GlobalAction.EditorSetupMode), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.A }, GlobalAction.EditorVerifyMode), + new KeyBinding(new[] { InputKey.J }, GlobalAction.EditorNudgeLeft), + new KeyBinding(new[] { InputKey.K }, GlobalAction.EditorNudgeRight), }; public IEnumerable InGameKeyBindings => new[] @@ -74,12 +90,18 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.Control, GlobalAction.HoldForHUD), }; + public IEnumerable SongSelectKeyBindings => new[] + { + new KeyBinding(InputKey.F1, GlobalAction.ToggleModSelection), + new KeyBinding(InputKey.F2, GlobalAction.SelectNextRandom), + new KeyBinding(new[] { InputKey.Shift, InputKey.F2 }, GlobalAction.SelectPreviousRandom), + new KeyBinding(InputKey.F3, GlobalAction.ToggleBeatmapOptions) + }; + public IEnumerable AudioControlKeyBindings => new[] { new KeyBinding(new[] { InputKey.Alt, InputKey.Up }, GlobalAction.IncreaseVolume), - new KeyBinding(new[] { InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.IncreaseVolume), new KeyBinding(new[] { InputKey.Alt, InputKey.Down }, GlobalAction.DecreaseVolume), - new KeyBinding(new[] { InputKey.Alt, InputKey.MouseWheelDown }, GlobalAction.DecreaseVolume), new KeyBinding(new[] { InputKey.Control, InputKey.F4 }, GlobalAction.ToggleMute), @@ -91,8 +113,20 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.F3, GlobalAction.MusicPlay) }; - protected override IEnumerable KeyBindingInputQueue => - handler == null ? base.KeyBindingInputQueue : base.KeyBindingInputQueue.Prepend(handler); + protected override IEnumerable KeyBindingInputQueue + { + get + { + // To ensure the global actions are handled with priority, this GlobalActionContainer is actually placed after game content. + // It does not contain children as expected, so we need to forward the NonPositionalInputQueue from the parent input manager to correctly + // allow the whole game to handle these actions. + + // An eventual solution to this hack is to create localised action containers for individual components like SongSelect, but this will take some rearranging. + var inputQueue = parentInputManager?.NonPositionalInputQueue ?? base.KeyBindingInputQueue; + + return handler != null ? inputQueue.Prepend(handler) : inputQueue; + } + } } public enum GlobalAction @@ -204,5 +238,30 @@ namespace osu.Game.Input.Bindings [Description("Toggle in-game interface")] ToggleInGameInterface, + + // Song select keybindings + [Description("Toggle Mod Select")] + ToggleModSelection, + + [Description("Random")] + SelectNextRandom, + + [Description("Rewind")] + SelectPreviousRandom, + + [Description("Beatmap Options")] + ToggleBeatmapOptions, + + [Description("Verify mode")] + EditorVerifyMode, + + [Description("Nudge selection left")] + EditorNudgeLeft, + + [Description("Nudge selection right")] + EditorNudgeRight, + + [Description("Toggle skin editor")] + ToggleSkinEditor, } } diff --git a/osu.Game/Input/Handlers/ReplayInputHandler.cs b/osu.Game/Input/Handlers/ReplayInputHandler.cs index 93ed3ca884..cd76000f98 100644 --- a/osu.Game/Input/Handlers/ReplayInputHandler.cs +++ b/osu.Game/Input/Handlers/ReplayInputHandler.cs @@ -32,10 +32,6 @@ namespace osu.Game.Input.Handlers public override bool Initialize(GameHost host) => true; - public override bool IsActive => true; - - public override int Priority => 0; - public class ReplayState : IInput where T : struct { diff --git a/osu.Game/Input/IdleTracker.cs b/osu.Game/Input/IdleTracker.cs index 63a6348b57..f3d531cf6c 100644 --- a/osu.Game/Input/IdleTracker.cs +++ b/osu.Game/Input/IdleTracker.cs @@ -6,13 +6,14 @@ using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; namespace osu.Game.Input { /// /// Track whether the end-user is in an idle state, based on their last interaction with the game. /// - public class IdleTracker : Component, IKeyBindingHandler, IHandleGlobalKeyboardInput + public class IdleTracker : Component, IKeyBindingHandler, IKeyBindingHandler, IHandleGlobalKeyboardInput { private readonly double timeToIdle; @@ -42,6 +43,12 @@ namespace osu.Game.Input RelativeSizeAxes = Axes.Both; } + protected override void LoadComplete() + { + base.LoadComplete(); + updateLastInteractionTime(); + } + protected override void Update() { base.Update(); @@ -52,6 +59,10 @@ namespace osu.Game.Input public void OnReleased(PlatformAction action) => updateLastInteractionTime(); + public bool OnPressed(GlobalAction action) => updateLastInteractionTime(); + + public void OnReleased(GlobalAction action) => updateLastInteractionTime(); + protected override bool Handle(UIEvent e) { switch (e) diff --git a/osu.Game/Input/KeyBindingStore.cs b/osu.Game/Input/KeyBindingStore.cs index b25b00eb84..3ef9923487 100644 --- a/osu.Game/Input/KeyBindingStore.cs +++ b/osu.Game/Input/KeyBindingStore.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Input.Bindings; using osu.Framework.Platform; @@ -16,6 +17,17 @@ namespace osu.Game.Input { public event Action KeyBindingChanged; + /// + /// Keys which should not be allowed for gameplay input purposes. + /// + private static readonly IEnumerable banned_keys = new[] + { + InputKey.MouseWheelDown, + InputKey.MouseWheelLeft, + InputKey.MouseWheelUp, + InputKey.MouseWheelRight + }; + public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null) : base(contextFactory, storage) { @@ -85,7 +97,6 @@ namespace osu.Game.Input /// /// The ruleset's internal ID. /// An optional variant. - /// public List Query(int? rulesetId = null, int? variant = null) => ContextFactory.Get().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList(); @@ -94,6 +105,9 @@ namespace osu.Game.Input using (ContextFactory.GetForWrite()) { var dbKeyBinding = (DatabasedKeyBinding)keyBinding; + + Debug.Assert(dbKeyBinding.RulesetID == null || CheckValidForGameplay(keyBinding.KeyCombination)); + Refresh(ref dbKeyBinding); if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination)) @@ -104,5 +118,16 @@ namespace osu.Game.Input KeyBindingChanged?.Invoke(); } + + public static bool CheckValidForGameplay(KeyCombination combination) + { + foreach (var key in banned_keys) + { + if (combination.Keys.Contains(key)) + return false; + } + + return true; + } } } diff --git a/osu.Game/Input/OsuUserInputManager.cs b/osu.Game/Input/OsuUserInputManager.cs new file mode 100644 index 0000000000..621f1e1fc9 --- /dev/null +++ b/osu.Game/Input/OsuUserInputManager.cs @@ -0,0 +1,38 @@ +// 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.Input; +using osuTK.Input; + +namespace osu.Game.Input +{ + public class OsuUserInputManager : UserInputManager + { + internal OsuUserInputManager() + { + } + + protected override MouseButtonEventManager CreateButtonEventManagerFor(MouseButton button) + { + switch (button) + { + case MouseButton.Right: + return new RightMouseManager(button); + } + + return base.CreateButtonEventManagerFor(button); + } + + private class RightMouseManager : MouseButtonEventManager + { + public RightMouseManager(MouseButton button) + : base(button) + { + } + + public override bool EnableDrag => true; // allow right-mouse dragging for absolute scroll in scroll containers. + public override bool EnableClick => false; + public override bool ChangeFocusOnClick => false; + } + } +} diff --git a/osu.Game/Localisation/ButtonSystem.ja.resx b/osu.Game/Localisation/ButtonSystem.ja.resx new file mode 100644 index 0000000000..02f3e7ce2f --- /dev/null +++ b/osu.Game/Localisation/ButtonSystem.ja.resx @@ -0,0 +1,38 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + ソロ + + + プレイリスト + + + 遊ぶ + + + マルチ + + + エディット + + + ブラウズ + + + 閉じる + + + 設定 + + diff --git a/osu.Game/Localisation/ButtonSystem.resx b/osu.Game/Localisation/ButtonSystem.resx new file mode 100644 index 0000000000..d72ffff8be --- /dev/null +++ b/osu.Game/Localisation/ButtonSystem.resx @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + solo + + + multi + + + playlists + + + play + + + edit + + + browse + + + settings + + + back + + + exit + + \ No newline at end of file diff --git a/osu.Game/Localisation/ButtonSystemStrings.cs b/osu.Game/Localisation/ButtonSystemStrings.cs new file mode 100644 index 0000000000..8083f80782 --- /dev/null +++ b/osu.Game/Localisation/ButtonSystemStrings.cs @@ -0,0 +1,59 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class ButtonSystemStrings + { + private const string prefix = @"osu.Game.Localisation.ButtonSystem"; + + /// + /// "solo" + /// + public static LocalisableString Solo => new TranslatableString(getKey(@"solo"), @"solo"); + + /// + /// "multi" + /// + public static LocalisableString Multi => new TranslatableString(getKey(@"multi"), @"multi"); + + /// + /// "playlists" + /// + public static LocalisableString Playlists => new TranslatableString(getKey(@"playlists"), @"playlists"); + + /// + /// "play" + /// + public static LocalisableString Play => new TranslatableString(getKey(@"play"), @"play"); + + /// + /// "edit" + /// + public static LocalisableString Edit => new TranslatableString(getKey(@"edit"), @"edit"); + + /// + /// "browse" + /// + public static LocalisableString Browse => new TranslatableString(getKey(@"browse"), @"browse"); + + /// + /// "settings" + /// + public static LocalisableString Settings => new TranslatableString(getKey(@"settings"), @"settings"); + + /// + /// "back" + /// + public static LocalisableString Back => new TranslatableString(getKey(@"back"), @"back"); + + /// + /// "exit" + /// + public static LocalisableString Exit => new TranslatableString(getKey(@"exit"), @"exit"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Localisation/Chat.resx b/osu.Game/Localisation/Chat.resx new file mode 100644 index 0000000000..055e351463 --- /dev/null +++ b/osu.Game/Localisation/Chat.resx @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + chat + + + join the real-time discussion + + \ No newline at end of file diff --git a/osu.Game/Localisation/ChatStrings.cs b/osu.Game/Localisation/ChatStrings.cs new file mode 100644 index 0000000000..daddb602ad --- /dev/null +++ b/osu.Game/Localisation/ChatStrings.cs @@ -0,0 +1,24 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class ChatStrings + { + private const string prefix = "osu.Game.Localisation.Chat"; + + /// + /// "chat" + /// + public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "chat"); + + /// + /// "join the real-time discussion" + /// + public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "join the real-time discussion"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/Common.resx b/osu.Game/Localisation/Common.resx new file mode 100644 index 0000000000..59de16a037 --- /dev/null +++ b/osu.Game/Localisation/Common.resx @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cancel + + \ No newline at end of file diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs new file mode 100644 index 0000000000..f448158191 --- /dev/null +++ b/osu.Game/Localisation/CommonStrings.cs @@ -0,0 +1,19 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class CommonStrings + { + private const string prefix = "osu.Game.Localisation.Common"; + + /// + /// "Cancel" + /// + public static LocalisableString Cancel => new TranslatableString(getKey("cancel"), "Cancel"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs new file mode 100644 index 0000000000..edcf264c7f --- /dev/null +++ b/osu.Game/Localisation/Language.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.ComponentModel; + +namespace osu.Game.Localisation +{ + public enum Language + { + [Description("English")] + en, + + [Description("日本語")] + ja + } +} diff --git a/osu.Game/Localisation/Notifications.resx b/osu.Game/Localisation/Notifications.resx new file mode 100644 index 0000000000..08db240ba2 --- /dev/null +++ b/osu.Game/Localisation/Notifications.resx @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + notifications + + + waiting for 'ya + + \ No newline at end of file diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs new file mode 100644 index 0000000000..092eec3a6b --- /dev/null +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -0,0 +1,24 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class NotificationsStrings + { + private const string prefix = "osu.Game.Localisation.Notifications"; + + /// + /// "notifications" + /// + public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "notifications"); + + /// + /// "waiting for 'ya" + /// + public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "waiting for 'ya"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/NowPlaying.resx b/osu.Game/Localisation/NowPlaying.resx new file mode 100644 index 0000000000..40fda3e25b --- /dev/null +++ b/osu.Game/Localisation/NowPlaying.resx @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + now playing + + + manage the currently playing track + + \ No newline at end of file diff --git a/osu.Game/Localisation/NowPlayingStrings.cs b/osu.Game/Localisation/NowPlayingStrings.cs new file mode 100644 index 0000000000..d742a56895 --- /dev/null +++ b/osu.Game/Localisation/NowPlayingStrings.cs @@ -0,0 +1,24 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class NowPlayingStrings + { + private const string prefix = "osu.Game.Localisation.NowPlaying"; + + /// + /// "now playing" + /// + public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "now playing"); + + /// + /// "manage the currently playing track" + /// + public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "manage the currently playing track"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs new file mode 100644 index 0000000000..7b21e1af42 --- /dev/null +++ b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs @@ -0,0 +1,69 @@ +// 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.Globalization; +using System.IO; +using System.Resources; +using System.Threading.Tasks; +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public class ResourceManagerLocalisationStore : ILocalisationStore + { + private readonly Dictionary resourceManagers = new Dictionary(); + + public ResourceManagerLocalisationStore(string cultureCode) + { + EffectiveCulture = new CultureInfo(cultureCode); + } + + public void Dispose() + { + } + + public string Get(string lookup) + { + var split = lookup.Split(':'); + + string ns = split[0]; + string key = split[1]; + + lock (resourceManagers) + { + if (!resourceManagers.TryGetValue(ns, out var manager)) + resourceManagers[ns] = manager = new ResourceManager(ns, GetType().Assembly); + + try + { + return manager.GetString(key, EffectiveCulture); + } + catch (MissingManifestResourceException) + { + // in the case the manifest is missing, it is likely that the user is adding code-first implementations of new localisation namespaces. + // it's fine to ignore this as localisation will fallback to default values. + return null; + } + } + } + + public Task GetAsync(string lookup) + { + return Task.FromResult(Get(lookup)); + } + + public Stream GetStream(string name) + { + throw new NotImplementedException(); + } + + public IEnumerable GetAvailableResources() + { + throw new NotImplementedException(); + } + + public CultureInfo EffectiveCulture { get; } + } +} diff --git a/osu.Game/Localisation/Settings.resx b/osu.Game/Localisation/Settings.resx new file mode 100644 index 0000000000..85c224cedf --- /dev/null +++ b/osu.Game/Localisation/Settings.resx @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + settings + + + change the way osu! behaves + + \ No newline at end of file diff --git a/osu.Game/Localisation/SettingsStrings.cs b/osu.Game/Localisation/SettingsStrings.cs new file mode 100644 index 0000000000..cfbd392691 --- /dev/null +++ b/osu.Game/Localisation/SettingsStrings.cs @@ -0,0 +1,24 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class SettingsStrings + { + private const string prefix = "osu.Game.Localisation.Settings"; + + /// + /// "settings" + /// + public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "settings"); + + /// + /// "change the way osu! behaves" + /// + public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "change the way osu! behaves"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.Designer.cs b/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.Designer.cs new file mode 100644 index 0000000000..2c100d39b9 --- /dev/null +++ b/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.Designer.cs @@ -0,0 +1,506 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using osu.Game.Database; + +namespace osu.Game.Migrations +{ + [DbContext(typeof(OsuDbContext))] + [Migration("20210412045700_RefreshVolumeBindingsAgain")] + partial class RefreshVolumeBindingsAgain + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("ApproachRate"); + + b.Property("CircleSize"); + + b.Property("DrainRate"); + + b.Property("OverallDifficulty"); + + b.Property("SliderMultiplier"); + + b.Property("SliderTickRate"); + + b.HasKey("ID"); + + b.ToTable("BeatmapDifficulty"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("AudioLeadIn"); + + b.Property("BPM"); + + b.Property("BaseDifficultyID"); + + b.Property("BeatDivisor"); + + b.Property("BeatmapSetInfoID"); + + b.Property("Countdown"); + + b.Property("DistanceSpacing"); + + b.Property("GridSize"); + + b.Property("Hash"); + + b.Property("Hidden"); + + b.Property("Length"); + + b.Property("LetterboxInBreaks"); + + b.Property("MD5Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapID"); + + b.Property("Path"); + + b.Property("RulesetID"); + + b.Property("SpecialStyle"); + + b.Property("StackLeniency"); + + b.Property("StarDifficulty"); + + b.Property("Status"); + + b.Property("StoredBookmarks"); + + b.Property("TimelineZoom"); + + b.Property("Version"); + + b.Property("WidescreenStoryboard"); + + b.HasKey("ID"); + + b.HasIndex("BaseDifficultyID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("Hash"); + + b.HasIndex("MD5Hash"); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("BeatmapInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Artist"); + + b.Property("ArtistUnicode"); + + b.Property("AudioFile"); + + b.Property("AuthorString") + .HasColumnName("Author"); + + b.Property("BackgroundFile"); + + b.Property("PreviewTime"); + + b.Property("Source"); + + b.Property("Tags"); + + b.Property("Title"); + + b.Property("TitleUnicode"); + + b.Property("VideoFile"); + + b.HasKey("ID"); + + b.ToTable("BeatmapMetadata"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("BeatmapSetInfoID"); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.HasKey("ID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("FileInfoID"); + + b.ToTable("BeatmapSetFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("DateAdded"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapSetID"); + + b.Property("Protected"); + + b.Property("Status"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapSetID") + .IsUnique(); + + b.ToTable("BeatmapSetInfo"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Key") + .HasColumnName("Key"); + + b.Property("RulesetID"); + + b.Property("SkinInfoID"); + + b.Property("StringValue") + .HasColumnName("Value"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("SkinInfoID"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("osu.Game.IO.FileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Hash"); + + b.Property("ReferenceCount"); + + b.HasKey("ID"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("ReferenceCount"); + + b.ToTable("FileInfo"); + }); + + modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("IntAction") + .HasColumnName("Action"); + + b.Property("KeysString") + .HasColumnName("Keys"); + + b.Property("RulesetID"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("IntAction"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("KeyBinding"); + }); + + modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Available"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.Property("ShortName"); + + b.HasKey("ID"); + + b.HasIndex("Available"); + + b.HasIndex("ShortName") + .IsUnique(); + + b.ToTable("RulesetInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("ScoreInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("ScoreInfoID"); + + b.ToTable("ScoreFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Accuracy") + .HasColumnType("DECIMAL(1,4)"); + + b.Property("BeatmapInfoID"); + + b.Property("Combo"); + + b.Property("Date"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MaxCombo"); + + b.Property("ModsJson") + .HasColumnName("Mods"); + + b.Property("OnlineScoreID"); + + b.Property("PP"); + + b.Property("Rank"); + + b.Property("RulesetID"); + + b.Property("StatisticsJson") + .HasColumnName("Statistics"); + + b.Property("TotalScore"); + + b.Property("UserID") + .HasColumnName("UserID"); + + b.Property("UserString") + .HasColumnName("User"); + + b.HasKey("ID"); + + b.HasIndex("BeatmapInfoID"); + + b.HasIndex("OnlineScoreID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("ScoreInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("SkinInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("SkinInfoID"); + + b.ToTable("SkinFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Creator"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("Name"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("SkinInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") + .WithMany() + .HasForeignKey("BaseDifficultyID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") + .WithMany("Beatmaps") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("Beatmaps") + .HasForeignKey("MetadataID"); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") + .WithMany("Files") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("BeatmapSets") + .HasForeignKey("MetadataID"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Settings") + .HasForeignKey("SkinInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Scoring.ScoreInfo") + .WithMany("Files") + .HasForeignKey("ScoreInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") + .WithMany("Scores") + .HasForeignKey("BeatmapInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Files") + .HasForeignKey("SkinInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.cs b/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.cs new file mode 100644 index 0000000000..155d6670a8 --- /dev/null +++ b/osu.Game/Migrations/20210412045700_RefreshVolumeBindingsAgain.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace osu.Game.Migrations +{ + public partial class RefreshVolumeBindingsAgain : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM KeyBinding WHERE action in (6,7)"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs new file mode 100644 index 0000000000..b808c648da --- /dev/null +++ b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.Designer.cs @@ -0,0 +1,508 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using osu.Game.Database; + +namespace osu.Game.Migrations +{ + [DbContext(typeof(OsuDbContext))] + [Migration("20210511060743_AddSkinInstantiationInfo")] + partial class AddSkinInstantiationInfo + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("ApproachRate"); + + b.Property("CircleSize"); + + b.Property("DrainRate"); + + b.Property("OverallDifficulty"); + + b.Property("SliderMultiplier"); + + b.Property("SliderTickRate"); + + b.HasKey("ID"); + + b.ToTable("BeatmapDifficulty"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("AudioLeadIn"); + + b.Property("BPM"); + + b.Property("BaseDifficultyID"); + + b.Property("BeatDivisor"); + + b.Property("BeatmapSetInfoID"); + + b.Property("Countdown"); + + b.Property("DistanceSpacing"); + + b.Property("EpilepsyWarning"); + + b.Property("GridSize"); + + b.Property("Hash"); + + b.Property("Hidden"); + + b.Property("Length"); + + b.Property("LetterboxInBreaks"); + + b.Property("MD5Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapID"); + + b.Property("Path"); + + b.Property("RulesetID"); + + b.Property("SpecialStyle"); + + b.Property("StackLeniency"); + + b.Property("StarDifficulty"); + + b.Property("Status"); + + b.Property("StoredBookmarks"); + + b.Property("TimelineZoom"); + + b.Property("Version"); + + b.Property("WidescreenStoryboard"); + + b.HasKey("ID"); + + b.HasIndex("BaseDifficultyID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("Hash"); + + b.HasIndex("MD5Hash"); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("BeatmapInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Artist"); + + b.Property("ArtistUnicode"); + + b.Property("AudioFile"); + + b.Property("AuthorString") + .HasColumnName("Author"); + + b.Property("BackgroundFile"); + + b.Property("PreviewTime"); + + b.Property("Source"); + + b.Property("Tags"); + + b.Property("Title"); + + b.Property("TitleUnicode"); + + b.HasKey("ID"); + + b.ToTable("BeatmapMetadata"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("BeatmapSetInfoID"); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.HasKey("ID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("FileInfoID"); + + b.ToTable("BeatmapSetFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("DateAdded"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapSetID"); + + b.Property("Protected"); + + b.Property("Status"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapSetID") + .IsUnique(); + + b.ToTable("BeatmapSetInfo"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Key") + .HasColumnName("Key"); + + b.Property("RulesetID"); + + b.Property("SkinInfoID"); + + b.Property("StringValue") + .HasColumnName("Value"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("SkinInfoID"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("osu.Game.IO.FileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Hash"); + + b.Property("ReferenceCount"); + + b.HasKey("ID"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("ReferenceCount"); + + b.ToTable("FileInfo"); + }); + + modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("IntAction") + .HasColumnName("Action"); + + b.Property("KeysString") + .HasColumnName("Keys"); + + b.Property("RulesetID"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("IntAction"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("KeyBinding"); + }); + + modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Available"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.Property("ShortName"); + + b.HasKey("ID"); + + b.HasIndex("Available"); + + b.HasIndex("ShortName") + .IsUnique(); + + b.ToTable("RulesetInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("ScoreInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("ScoreInfoID"); + + b.ToTable("ScoreFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Accuracy") + .HasColumnType("DECIMAL(1,4)"); + + b.Property("BeatmapInfoID"); + + b.Property("Combo"); + + b.Property("Date"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MaxCombo"); + + b.Property("ModsJson") + .HasColumnName("Mods"); + + b.Property("OnlineScoreID"); + + b.Property("PP"); + + b.Property("Rank"); + + b.Property("RulesetID"); + + b.Property("StatisticsJson") + .HasColumnName("Statistics"); + + b.Property("TotalScore"); + + b.Property("UserID") + .HasColumnName("UserID"); + + b.Property("UserString") + .HasColumnName("User"); + + b.HasKey("ID"); + + b.HasIndex("BeatmapInfoID"); + + b.HasIndex("OnlineScoreID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("ScoreInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("SkinInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("SkinInfoID"); + + b.ToTable("SkinFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Creator"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("SkinInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") + .WithMany() + .HasForeignKey("BaseDifficultyID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") + .WithMany("Beatmaps") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("Beatmaps") + .HasForeignKey("MetadataID"); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") + .WithMany("Files") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("BeatmapSets") + .HasForeignKey("MetadataID"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Settings") + .HasForeignKey("SkinInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Scoring.ScoreInfo") + .WithMany("Files") + .HasForeignKey("ScoreInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") + .WithMany("Scores") + .HasForeignKey("BeatmapInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Files") + .HasForeignKey("SkinInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs new file mode 100644 index 0000000000..1d5b0769a4 --- /dev/null +++ b/osu.Game/Migrations/20210511060743_AddSkinInstantiationInfo.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace osu.Game.Migrations +{ + public partial class AddSkinInstantiationInfo : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InstantiationInfo", + table: "SkinInfo", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "InstantiationInfo", + table: "SkinInfo"); + } + } +} diff --git a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.Designer.cs b/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.Designer.cs new file mode 100644 index 0000000000..89bab3a0fa --- /dev/null +++ b/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.Designer.cs @@ -0,0 +1,511 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using osu.Game.Database; + +namespace osu.Game.Migrations +{ + [DbContext(typeof(OsuDbContext))] + [Migration("20210514062639_AddAuthorIdToBeatmapMetadata")] + partial class AddAuthorIdToBeatmapMetadata + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("ApproachRate"); + + b.Property("CircleSize"); + + b.Property("DrainRate"); + + b.Property("OverallDifficulty"); + + b.Property("SliderMultiplier"); + + b.Property("SliderTickRate"); + + b.HasKey("ID"); + + b.ToTable("BeatmapDifficulty"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("AudioLeadIn"); + + b.Property("BPM"); + + b.Property("BaseDifficultyID"); + + b.Property("BeatDivisor"); + + b.Property("BeatmapSetInfoID"); + + b.Property("Countdown"); + + b.Property("DistanceSpacing"); + + b.Property("EpilepsyWarning"); + + b.Property("GridSize"); + + b.Property("Hash"); + + b.Property("Hidden"); + + b.Property("Length"); + + b.Property("LetterboxInBreaks"); + + b.Property("MD5Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapID"); + + b.Property("Path"); + + b.Property("RulesetID"); + + b.Property("SpecialStyle"); + + b.Property("StackLeniency"); + + b.Property("StarDifficulty"); + + b.Property("Status"); + + b.Property("StoredBookmarks"); + + b.Property("TimelineZoom"); + + b.Property("Version"); + + b.Property("WidescreenStoryboard"); + + b.HasKey("ID"); + + b.HasIndex("BaseDifficultyID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("Hash"); + + b.HasIndex("MD5Hash"); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("BeatmapInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapMetadata", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Artist"); + + b.Property("ArtistUnicode"); + + b.Property("AudioFile"); + + b.Property("AuthorID") + .HasColumnName("AuthorID"); + + b.Property("AuthorString") + .HasColumnName("Author"); + + b.Property("BackgroundFile"); + + b.Property("PreviewTime"); + + b.Property("Source"); + + b.Property("Tags"); + + b.Property("Title"); + + b.Property("TitleUnicode"); + + b.HasKey("ID"); + + b.ToTable("BeatmapMetadata"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("BeatmapSetInfoID"); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.HasKey("ID"); + + b.HasIndex("BeatmapSetInfoID"); + + b.HasIndex("FileInfoID"); + + b.ToTable("BeatmapSetFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("DateAdded"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MetadataID"); + + b.Property("OnlineBeatmapSetID"); + + b.Property("Protected"); + + b.Property("Status"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("MetadataID"); + + b.HasIndex("OnlineBeatmapSetID") + .IsUnique(); + + b.ToTable("BeatmapSetInfo"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Key") + .HasColumnName("Key"); + + b.Property("RulesetID"); + + b.Property("SkinInfoID"); + + b.Property("StringValue") + .HasColumnName("Value"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("SkinInfoID"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("osu.Game.IO.FileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Hash"); + + b.Property("ReferenceCount"); + + b.HasKey("ID"); + + b.HasIndex("Hash") + .IsUnique(); + + b.HasIndex("ReferenceCount"); + + b.ToTable("FileInfo"); + }); + + modelBuilder.Entity("osu.Game.Input.Bindings.DatabasedKeyBinding", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("IntAction") + .HasColumnName("Action"); + + b.Property("KeysString") + .HasColumnName("Keys"); + + b.Property("RulesetID"); + + b.Property("Variant"); + + b.HasKey("ID"); + + b.HasIndex("IntAction"); + + b.HasIndex("RulesetID", "Variant"); + + b.ToTable("KeyBinding"); + }); + + modelBuilder.Entity("osu.Game.Rulesets.RulesetInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Available"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.Property("ShortName"); + + b.HasKey("ID"); + + b.HasIndex("Available"); + + b.HasIndex("ShortName") + .IsUnique(); + + b.ToTable("RulesetInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("ScoreInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("ScoreInfoID"); + + b.ToTable("ScoreFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Accuracy") + .HasColumnType("DECIMAL(1,4)"); + + b.Property("BeatmapInfoID"); + + b.Property("Combo"); + + b.Property("Date"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("MaxCombo"); + + b.Property("ModsJson") + .HasColumnName("Mods"); + + b.Property("OnlineScoreID"); + + b.Property("PP"); + + b.Property("Rank"); + + b.Property("RulesetID"); + + b.Property("StatisticsJson") + .HasColumnName("Statistics"); + + b.Property("TotalScore"); + + b.Property("UserID") + .HasColumnName("UserID"); + + b.Property("UserString") + .HasColumnName("User"); + + b.HasKey("ID"); + + b.HasIndex("BeatmapInfoID"); + + b.HasIndex("OnlineScoreID") + .IsUnique(); + + b.HasIndex("RulesetID"); + + b.ToTable("ScoreInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("FileInfoID"); + + b.Property("Filename") + .IsRequired(); + + b.Property("SkinInfoID"); + + b.HasKey("ID"); + + b.HasIndex("FileInfoID"); + + b.HasIndex("SkinInfoID"); + + b.ToTable("SkinFileInfo"); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinInfo", b => + { + b.Property("ID") + .ValueGeneratedOnAdd(); + + b.Property("Creator"); + + b.Property("DeletePending"); + + b.Property("Hash"); + + b.Property("InstantiationInfo"); + + b.Property("Name"); + + b.HasKey("ID"); + + b.HasIndex("DeletePending"); + + b.HasIndex("Hash") + .IsUnique(); + + b.ToTable("SkinInfo"); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapDifficulty", "BaseDifficulty") + .WithMany() + .HasForeignKey("BaseDifficultyID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo", "BeatmapSet") + .WithMany("Beatmaps") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("Beatmaps") + .HasForeignKey("MetadataID"); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetFileInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapSetInfo") + .WithMany("Files") + .HasForeignKey("BeatmapSetInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Beatmaps.BeatmapSetInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapMetadata", "Metadata") + .WithMany("BeatmapSets") + .HasForeignKey("MetadataID"); + }); + + modelBuilder.Entity("osu.Game.Configuration.DatabasedSetting", b => + { + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Settings") + .HasForeignKey("SkinInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Scoring.ScoreInfo") + .WithMany("Files") + .HasForeignKey("ScoreInfoID"); + }); + + modelBuilder.Entity("osu.Game.Scoring.ScoreInfo", b => + { + b.HasOne("osu.Game.Beatmaps.BeatmapInfo", "Beatmap") + .WithMany("Scores") + .HasForeignKey("BeatmapInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Rulesets.RulesetInfo", "Ruleset") + .WithMany() + .HasForeignKey("RulesetID") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("osu.Game.Skinning.SkinFileInfo", b => + { + b.HasOne("osu.Game.IO.FileInfo", "FileInfo") + .WithMany() + .HasForeignKey("FileInfoID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("osu.Game.Skinning.SkinInfo") + .WithMany("Files") + .HasForeignKey("SkinInfoID") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.cs b/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.cs new file mode 100644 index 0000000000..98fe9b5e13 --- /dev/null +++ b/osu.Game/Migrations/20210514062639_AddAuthorIdToBeatmapMetadata.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace osu.Game.Migrations +{ + public partial class AddAuthorIdToBeatmapMetadata : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AuthorID", + table: "BeatmapMetadata", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AuthorID", + table: "BeatmapMetadata"); + } + } +} diff --git a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs index ec4461ca56..f518cfb42b 100644 --- a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs +++ b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs @@ -126,6 +126,9 @@ namespace osu.Game.Migrations b.Property("AudioFile"); + b.Property("AuthorID") + .HasColumnName("AuthorID"); + b.Property("AuthorString") .HasColumnName("Author"); @@ -141,8 +144,6 @@ namespace osu.Game.Migrations b.Property("TitleUnicode"); - b.Property("VideoFile"); - b.HasKey("ID"); b.ToTable("BeatmapMetadata"); @@ -352,7 +353,7 @@ namespace osu.Game.Migrations b.Property("TotalScore"); - b.Property("UserID") + b.Property("UserID") .HasColumnName("UserID"); b.Property("UserString") @@ -402,6 +403,8 @@ namespace osu.Game.Migrations b.Property("Hash"); + b.Property("InstantiationInfo"); + b.Property("Name"); b.HasKey("ID"); diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 8ffa0221c8..1686595512 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -89,7 +89,7 @@ namespace osu.Game.Online.API thread.Start(); } - private void onTokenChanged(ValueChangedEvent e) => config.Set(OsuSetting.Token, config.Get(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); + private void onTokenChanged(ValueChangedEvent e) => config.SetValue(OsuSetting.Token, config.Get(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); internal new void Schedule(Action action) => base.Schedule(action); @@ -134,7 +134,7 @@ namespace osu.Game.Online.API state.Value = APIState.Connecting; // save the username at this point, if the user requested for it to be. - config.Set(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); + config.SetValue(OsuSetting.Username, config.Get(OsuSetting.SaveUsername) ? ProvidedUsername : string.Empty); if (!authentication.HasValidAccessToken && !authentication.AuthenticateWithLogin(ProvidedUsername, password)) { @@ -246,7 +246,8 @@ namespace osu.Game.Online.API this.password = password; } - public IHubClientConnector GetHubConnector(string clientName, string endpoint) => new HubClientConnector(clientName, endpoint, this, versionHash); + public IHubClientConnector GetHubConnector(string clientName, string endpoint) => + new HubClientConnector(clientName, endpoint, this, versionHash); public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { @@ -269,7 +270,7 @@ namespace osu.Game.Online.API { try { - return JObject.Parse(req.GetResponseString()).SelectToken("form_error", true).AsNonNull().ToObject(); + return JObject.Parse(req.GetResponseString().AsNonNull()).SelectToken("form_error", true).AsNonNull().ToObject(); } catch { @@ -373,7 +374,13 @@ namespace osu.Game.Online.API public void Queue(APIRequest request) { - lock (queue) queue.Enqueue(request); + lock (queue) + { + if (state.Value == APIState.Offline) + return; + + queue.Enqueue(request); + } } private void flushQueue(bool failOldRequests = true) @@ -394,8 +401,6 @@ namespace osu.Game.Online.API public void Logout() { - flushQueue(); - password = null; authentication.Clear(); @@ -407,6 +412,7 @@ namespace osu.Game.Online.API }); state.Value = APIState.Offline; + flushQueue(); } private static User createGuestUser() => new GuestUser(); diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs index bff08b0515..4427c82a8b 100644 --- a/osu.Game/Online/API/APIMod.cs +++ b/osu.Game/Online/API/APIMod.cs @@ -11,11 +11,12 @@ using osu.Framework.Bindables; using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Online.API { [MessagePackObject] - public class APIMod : IMod + public class APIMod : IMod, IEquatable { [JsonProperty("acronym")] [Key(0)] @@ -63,7 +64,16 @@ namespace osu.Game.Online.API return resultMod; } - public bool Equals(IMod other) => Acronym == other?.Acronym; + public bool Equals(IMod other) => other is APIMod them && Equals(them); + + public bool Equals(APIMod other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Acronym == other.Acronym && + Settings.SequenceEqual(other.Settings, ModSettingsEqualityComparer.Default); + } public override string ToString() { @@ -72,5 +82,20 @@ namespace osu.Game.Online.API return $"{Acronym}"; } + + private class ModSettingsEqualityComparer : IEqualityComparer> + { + public static ModSettingsEqualityComparer Default { get; } = new ModSettingsEqualityComparer(); + + public bool Equals(KeyValuePair x, KeyValuePair y) + { + object xValue = ModUtils.GetSettingUnderlyingValue(x.Value); + object yValue = ModUtils.GetSettingUnderlyingValue(y.Value); + + return x.Key == y.Key && EqualityComparer.Default.Equals(xValue, yValue); + } + + public int GetHashCode(KeyValuePair obj) => HashCode.Combine(obj.Key, ModUtils.GetSettingUnderlyingValue(obj.Value)); + } } } diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index a7174324d8..1a6868cfa4 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -131,8 +131,11 @@ namespace osu.Game.Online.API { } + private bool succeeded; + internal virtual void TriggerSuccess() { + succeeded = true; Success?.Invoke(); } @@ -145,10 +148,7 @@ namespace osu.Game.Online.API public void Fail(Exception e) { - if (WebRequest?.Completed == true) - return; - - if (cancelled) + if (succeeded || cancelled) return; cancelled = true; @@ -181,9 +181,13 @@ namespace osu.Game.Online.API /// Whether we are in a failed or cancelled state. private bool checkAndScheduleFailure() { - if (API == null || pendingFailure == null) return cancelled; + if (pendingFailure == null) return cancelled; + + if (API == null) + pendingFailure(); + else + API.Schedule(pendingFailure); - API.Schedule(pendingFailure); pendingFailure = null; return true; } diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 943b52db88..52f2365165 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -34,8 +34,9 @@ namespace osu.Game.Online.API /// /// Provide handling logic for an arbitrary API request. + /// Should return true is a request was handled. If null or false return, the request will be failed with a . /// - public Action HandleRequest; + public Func HandleRequest; private readonly Bindable state = new Bindable(APIState.Online); @@ -55,7 +56,12 @@ namespace osu.Game.Online.API public virtual void Queue(APIRequest request) { - HandleRequest?.Invoke(request); + if (HandleRequest?.Invoke(request) != true) + { + // this will fail due to not receiving an APIAccess, and trigger a failure on the request. + // this is intended - any request in testing that needs non-failures should use HandleRequest. + request.Perform(this); + } } public void Perform(APIRequest request) => HandleRequest?.Invoke(request); diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs index dd854acc32..81ecc74ddb 100644 --- a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs +++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs @@ -3,11 +3,10 @@ using System.Buffers; using System.Collections.Generic; -using System.Diagnostics; using System.Text; using MessagePack; using MessagePack.Formatters; -using osu.Framework.Bindables; +using osu.Game.Utils; namespace osu.Game.Online.API { @@ -24,36 +23,7 @@ namespace osu.Game.Online.API var stringBytes = new ReadOnlySequence(Encoding.UTF8.GetBytes(kvp.Key)); writer.WriteString(in stringBytes); - switch (kvp.Value) - { - case Bindable d: - primitiveFormatter.Serialize(ref writer, d.Value, options); - break; - - case Bindable i: - primitiveFormatter.Serialize(ref writer, i.Value, options); - break; - - case Bindable f: - primitiveFormatter.Serialize(ref writer, f.Value, options); - break; - - case Bindable b: - primitiveFormatter.Serialize(ref writer, b.Value, options); - break; - - case IBindable u: - // A mod with unknown (e.g. enum) generic type. - var valueMethod = u.GetType().GetProperty(nameof(IBindable.Value)); - Debug.Assert(valueMethod != null); - primitiveFormatter.Serialize(ref writer, valueMethod.GetValue(u), options); - break; - - default: - // fall back for non-bindable cases. - primitiveFormatter.Serialize(ref writer, kvp.Value, options); - break; - } + primitiveFormatter.Serialize(ref writer, ModUtils.GetSettingUnderlyingValue(kvp.Value), options); } } diff --git a/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs b/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs index 707c59436d..2898955de7 100644 --- a/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.cs +++ b/osu.Game/Online/API/Requests/DownloadBeatmapSetRequest.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.Framework.IO.Network; using osu.Game.Beatmaps; namespace osu.Game.Online.API.Requests @@ -15,6 +16,15 @@ namespace osu.Game.Online.API.Requests this.noVideo = noVideo; } + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Timeout = 60000; + return req; + } + + protected override string FileExtension => ".osz"; + protected override string Target => $@"beatmapsets/{Model.OnlineBeatmapSetID}/download{(noVideo ? "?noVideo=1" : "")}"; } } diff --git a/osu.Game/Online/API/Requests/GetNewsRequest.cs b/osu.Game/Online/API/Requests/GetNewsRequest.cs index 36d9dc0652..992ccc6d59 100644 --- a/osu.Game/Online/API/Requests/GetNewsRequest.cs +++ b/osu.Game/Online/API/Requests/GetNewsRequest.cs @@ -8,10 +8,12 @@ namespace osu.Game.Online.API.Requests { public class GetNewsRequest : APIRequest { + private readonly int? year; private readonly Cursor cursor; - public GetNewsRequest(Cursor cursor = null) + public GetNewsRequest(int? year = null, Cursor cursor = null) { + this.year = year; this.cursor = cursor; } @@ -19,6 +21,10 @@ namespace osu.Game.Online.API.Requests { var req = base.CreateWebRequest(); req.AddCursor(cursor); + + if (year.HasValue) + req.AddParameter("year", year.Value.ToString()); + return req; } diff --git a/osu.Game/Online/API/Requests/GetNewsResponse.cs b/osu.Game/Online/API/Requests/GetNewsResponse.cs index 835289a51d..98f76d105c 100644 --- a/osu.Game/Online/API/Requests/GetNewsResponse.cs +++ b/osu.Game/Online/API/Requests/GetNewsResponse.cs @@ -11,5 +11,8 @@ namespace osu.Game.Online.API.Requests { [JsonProperty("news_posts")] public IEnumerable NewsPosts; + + [JsonProperty("news_sidebar")] + public APINewsSidebar SidebarMetadata; } } diff --git a/osu.Game/Online/API/Requests/GetWikiRequest.cs b/osu.Game/Online/API/Requests/GetWikiRequest.cs new file mode 100644 index 0000000000..248fcc03e3 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetWikiRequest.cs @@ -0,0 +1,21 @@ +// 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.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetWikiRequest : APIRequest + { + private readonly string path; + private readonly string locale; + + public GetWikiRequest(string path, string locale = "en") + { + this.path = path; + this.locale = locale; + } + + protected override string Target => $"wiki/{locale}/{path}"; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APINewsSidebar.cs b/osu.Game/Online/API/Requests/Responses/APINewsSidebar.cs new file mode 100644 index 0000000000..b8d6469a1d --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APINewsSidebar.cs @@ -0,0 +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 Newtonsoft.Json; +using System.Collections.Generic; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APINewsSidebar + { + [JsonProperty("current_year")] + public int CurrentYear { get; set; } + + [JsonProperty("news_posts")] + public IEnumerable NewsPosts { get; set; } + + [JsonProperty("years")] + public int[] Years { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIWikiPage.cs b/osu.Game/Online/API/Requests/Responses/APIWikiPage.cs new file mode 100644 index 0000000000..957396b17a --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIWikiPage.cs @@ -0,0 +1,32 @@ +// 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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIWikiPage + { + [JsonProperty("layout")] + public string Layout { get; set; } + + [JsonProperty("locale")] + public string Locale { get; set; } + + [JsonProperty("markdown")] + public string Markdown { get; set; } + + [JsonProperty("path")] + public string Path { get; set; } + + [JsonProperty("subtitle")] + public string Subtitle { get; set; } + + [JsonProperty("tags")] + public List Tags { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs index 5360d36f3d..f1cb02fb10 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs @@ -15,6 +15,9 @@ namespace osu.Game.Online.API.Requests { public class SearchBeatmapSetsRequest : APIRequest { + [CanBeNull] + public IReadOnlyCollection General { get; } + public SearchCategory SearchCategory { get; } public SortCriteria SortCriteria { get; } @@ -45,6 +48,7 @@ namespace osu.Game.Online.API.Requests string query, RulesetInfo ruleset, Cursor cursor = null, + IReadOnlyCollection general = null, SearchCategory searchCategory = SearchCategory.Any, SortCriteria sortCriteria = SortCriteria.Ranked, SortDirection sortDirection = SortDirection.Descending, @@ -59,6 +63,7 @@ namespace osu.Game.Online.API.Requests this.ruleset = ruleset; this.cursor = cursor; + General = general; SearchCategory = searchCategory; SortCriteria = sortCriteria; SortDirection = sortDirection; @@ -75,6 +80,9 @@ namespace osu.Game.Online.API.Requests var req = base.CreateWebRequest(); req.AddParameter("q", query); + if (General != null && General.Any()) + req.AddParameter("c", string.Join('.', General.Select(e => e.ToString().ToLowerInvariant()))); + if (ruleset.ID.HasValue) req.AddParameter("m", ruleset.ID.Value.ToString()); diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 036ec4d0f3..a980f4c54b 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -152,7 +152,7 @@ namespace osu.Game.Online.Chat createNewPrivateMessageRequest.Failure += exception => { - Logger.Error(exception, "Posting message failed."); + handlePostException(exception); target.ReplaceMessage(message, null); dequeueAndRun(); }; @@ -171,7 +171,7 @@ namespace osu.Game.Online.Chat req.Failure += exception => { - Logger.Error(exception, "Posting message failed."); + handlePostException(exception); target.ReplaceMessage(message, null); dequeueAndRun(); }; @@ -184,6 +184,14 @@ namespace osu.Game.Online.Chat dequeueAndRun(); } + private static void handlePostException(Exception exception) + { + if (exception is APIException apiException) + Logger.Log(apiException.Message, level: LogLevel.Important); + else + Logger.Error(exception, "Posting message failed."); + } + /// /// Posts a command locally. Commands like /help will result in a help message written in the current channel. /// diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs index d27a3fbffe..e7f47833a2 100644 --- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; namespace osu.Game.Online.Chat @@ -20,7 +21,10 @@ namespace osu.Game.Online.Chat /// /// Each word part of a chat link (split for word-wrap support). /// - public List Parts; + public readonly List Parts; + + [Resolved(CanBeNull = true)] + private OverlayColourProvider overlayColourProvider { get; set; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parts.Any(d => d.ReceivePositionalInputAt(screenSpacePos)); @@ -34,7 +38,7 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader] private void load(OsuColour colours) { - IdleColour = colours.Blue; + IdleColour = overlayColourProvider?.Light2 ?? colours.Blue; } protected override IEnumerable EffectTargets => Parts; diff --git a/osu.Game/Online/Chat/InfoMessage.cs b/osu.Game/Online/Chat/InfoMessage.cs index 8dce188804..cea336aae2 100644 --- a/osu.Game/Online/Chat/InfoMessage.cs +++ b/osu.Game/Online/Chat/InfoMessage.cs @@ -8,10 +8,8 @@ namespace osu.Game.Online.Chat { public class InfoMessage : LocalMessage { - private static int infoID = -1; - public InfoMessage(string message) - : base(infoID--) + : base(null) { Timestamp = DateTimeOffset.Now; Content = message; diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index 2e41038a59..30753b3920 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -59,7 +59,7 @@ namespace osu.Game.Online.Chat return Id.Value.CompareTo(other.Id.Value); } - public virtual bool Equals(Message other) => Id == other?.Id; + public virtual bool Equals(Message other) => Id.HasValue && Id == other?.Id; // ReSharper disable once ImpureMethodCallOnReadonlyValueField public override int GetHashCode() => Id.GetHashCode(); diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index b80720a0aa..df14d7eb1c 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +#nullable enable + namespace osu.Game.Online.Chat { public static class MessageFormatter @@ -61,7 +63,7 @@ namespace osu.Game.Online.Chat private static string websiteRootUrl = "osu.ppy.sh"; - private static void handleMatches(Regex regex, string display, string link, MessageFormatterResult result, int startIndex = 0, LinkAction? linkActionOverride = null, char[] escapeChars = null) + private static void handleMatches(Regex regex, string display, string link, MessageFormatterResult result, int startIndex = 0, LinkAction? linkActionOverride = null, char[]? escapeChars = null) { int captureOffset = 0; @@ -167,15 +169,18 @@ namespace osu.Game.Online.Chat case "u": case "users": return new LinkDetails(LinkAction.OpenUserProfile, mainArg); + + case "wiki": + return new LinkDetails(LinkAction.OpenWiki, string.Join('/', args.Skip(3))); } } - return new LinkDetails(LinkAction.External, null); + break; case "osu": // every internal link also needs some kind of argument if (args.Length < 3) - return new LinkDetails(LinkAction.External, null); + break; LinkAction linkType; @@ -217,7 +222,7 @@ namespace osu.Game.Online.Chat return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]); } - return new LinkDetails(LinkAction.External, null); + return new LinkDetails(LinkAction.External, url); } private static MessageFormatterResult format(string toFormat, int startIndex = 0, int space = 3) @@ -311,7 +316,8 @@ namespace osu.Game.Online.Chat JoinMultiplayerMatch, Spectate, OpenUserProfile, - Custom + OpenWiki, + Custom, } public class Link : IComparable diff --git a/osu.Game/Online/DownloadTrackingComposite.cs b/osu.Game/Online/DownloadTrackingComposite.cs index 52042c266b..d9599481e7 100644 --- a/osu.Game/Online/DownloadTrackingComposite.cs +++ b/osu.Game/Online/DownloadTrackingComposite.cs @@ -123,13 +123,13 @@ namespace osu.Game.Online { if (attachedRequest.Progress == 1) { - State.Value = DownloadState.Importing; Progress.Value = 1; + State.Value = DownloadState.Importing; } else { - State.Value = DownloadState.Downloading; Progress.Value = attachedRequest.Progress; + State.Value = DownloadState.Downloading; attachedRequest.Failure += onRequestFailure; attachedRequest.DownloadProgressed += onRequestProgress; diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index fdb21c5000..3839762e46 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -79,7 +79,7 @@ namespace osu.Game.Online { cancelExistingConnect(); - if (!await connectionLock.WaitAsync(10000)) + if (!await connectionLock.WaitAsync(10000).ConfigureAwait(false)) throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck."); try @@ -88,7 +88,7 @@ namespace osu.Game.Online { // ensure any previous connection was disposed. // this will also create a new cancellation token source. - await disconnect(false); + await disconnect(false).ConfigureAwait(false); // this token will be valid for the scope of this connection. // if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere. @@ -103,7 +103,7 @@ namespace osu.Game.Online // importantly, rebuild the connection each attempt to get an updated access token. CurrentConnection = buildConnection(cancellationToken); - await CurrentConnection.StartAsync(cancellationToken); + await CurrentConnection.StartAsync(cancellationToken).ConfigureAwait(false); Logger.Log($"{clientName} connected!", LoggingTarget.Network); isConnected.Value = true; @@ -119,7 +119,7 @@ namespace osu.Game.Online Logger.Log($"{clientName} connection error: {e}", LoggingTarget.Network); // retry on any failure. - await Task.Delay(5000, cancellationToken); + await Task.Delay(5000, cancellationToken).ConfigureAwait(false); } } } @@ -174,14 +174,14 @@ namespace osu.Game.Online if (takeLock) { - if (!await connectionLock.WaitAsync(10000)) + if (!await connectionLock.WaitAsync(10000).ConfigureAwait(false)) throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck."); } try { if (CurrentConnection != null) - await CurrentConnection.DisposeAsync(); + await CurrentConnection.DisposeAsync().ConfigureAwait(false); } finally { diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 5608002513..795540b65d 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -61,6 +61,9 @@ namespace osu.Game.Online.Leaderboards [Resolved(CanBeNull = true)] private SongSelect songSelect { get; set; } + [Resolved] + private ScoreManager scoreManager { get; set; } + public LeaderboardScore(ScoreInfo score, int? rank, bool allowHighlight = true) { this.score = score; @@ -388,6 +391,9 @@ namespace osu.Game.Online.Leaderboards if (score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null) items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = score.Mods)); + if (score.Files?.Count > 0) + items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(score))); + if (score.ID != 0) items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 95d76f384f..2e65f7cf1c 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -3,128 +3,640 @@ #nullable enable +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.Rooms; +using osu.Game.Online.Rooms.RoomStatuses; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Users; +using osu.Game.Utils; namespace osu.Game.Online.Multiplayer { - public class MultiplayerClient : StatefulMultiplayerClient + public abstract class MultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer { - private readonly string endpoint; + /// + /// Invoked when any change occurs to the multiplayer room. + /// + public event Action? RoomUpdated; - private IHubClientConnector? connector; + /// + /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. + /// + public event Action? LoadRequested; - public override IBindable IsConnected { get; } = new BindableBool(); + /// + /// Invoked when the multiplayer server requests gameplay to be started. + /// + public event Action? MatchStarted; - private HubConnection? connection => connector?.CurrentConnection; + /// + /// Invoked when the multiplayer server has finished collating results. + /// + public event Action? ResultsReady; - public MultiplayerClient(EndpointConfiguration endpoints) + /// + /// Whether the is currently connected. + /// This is NOT thread safe and usage should be scheduled. + /// + public abstract IBindable IsConnected { get; } + + /// + /// The joined . + /// + public MultiplayerRoom? Room { get; private set; } + + /// + /// The users in the joined which are participating in the current gameplay loop. + /// + public readonly BindableList CurrentMatchPlayingUserIds = new BindableList(); + + public readonly Bindable CurrentMatchPlayingItem = new Bindable(); + + /// + /// The corresponding to the local player, if available. + /// + public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == API.LocalUser.Value.Id); + + /// + /// Whether the is the host in . + /// + public bool IsHost { - endpoint = endpoints.MultiplayerEndpointUrl; - } - - [BackgroundDependencyLoader] - private void load(IAPIProvider api) - { - connector = api.GetHubConnector(nameof(MultiplayerClient), endpoint); - - if (connector != null) + get { - connector.ConfigureConnection = connection => - { - // this is kind of SILLY - // https://github.com/dotnet/aspnetcore/issues/15198 - connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged); - connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined); - connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft); - connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged); - connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged); - connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); - connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); - connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted); - connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); - connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); - connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); - }; - - IsConnected.BindTo(connector.IsConnected); + var localUser = LocalUser; + return localUser != null && Room?.Host != null && localUser.Equals(Room.Host); } } - protected override Task JoinRoom(long roomId) - { - if (!IsConnected.Value) - return Task.FromCanceled(new CancellationToken(true)); + [Resolved] + protected IAPIProvider API { get; private set; } = null!; - return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId); + [Resolved] + protected RulesetStore Rulesets { get; private set; } = null!; + + [Resolved] + private UserLookupCache userLookupCache { get; set; } = null!; + + private Room? apiRoom; + + [BackgroundDependencyLoader] + private void load() + { + IsConnected.BindValueChanged(connected => + { + // clean up local room state on server disconnect. + if (!connected.NewValue && Room != null) + { + Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); + LeaveRoom(); + } + }); } - protected override Task LeaveRoomInternal() - { - if (!IsConnected.Value) - return Task.FromCanceled(new CancellationToken(true)); + private readonly TaskChain joinOrLeaveTaskChain = new TaskChain(); + private CancellationTokenSource? joinCancellationSource; - return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom)); + /// + /// Joins the for a given API . + /// + /// The API . + public async Task JoinRoom(Room room) + { + var cancellationSource = joinCancellationSource = new CancellationTokenSource(); + + await joinOrLeaveTaskChain.Add(async () => + { + if (Room != null) + throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); + + Debug.Assert(room.RoomID.Value != null); + + // Join the server-side room. + var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false); + Debug.Assert(joinedRoom != null); + + // Populate users. + Debug.Assert(joinedRoom.Users != null); + await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false); + + // Update the stored room (must be done on update thread for thread-safety). + await scheduleAsync(() => + { + Room = joinedRoom; + apiRoom = room; + foreach (var user in joinedRoom.Users) + updateUserPlayingState(user.UserID, user.State); + }, cancellationSource.Token).ConfigureAwait(false); + + // Update room settings. + await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token).ConfigureAwait(false); + }, cancellationSource.Token).ConfigureAwait(false); } - public override Task TransferHost(int userId) + /// + /// Joins the with a given ID. + /// + /// The room ID. + /// The joined . + protected abstract Task JoinRoom(long roomId); + + public Task LeaveRoom() { - if (!IsConnected.Value) + // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled. + // This includes the setting of Room itself along with the initial update of the room settings on join. + joinCancellationSource?.Cancel(); + + // Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background. + // However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed. + // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time. + var scheduledReset = scheduleAsync(() => + { + apiRoom = null; + Room = null; + CurrentMatchPlayingUserIds.Clear(); + + RoomUpdated?.Invoke(); + }); + + return joinOrLeaveTaskChain.Add(async () => + { + await scheduledReset.ConfigureAwait(false); + await LeaveRoomInternal().ConfigureAwait(false); + }); + } + + protected abstract Task LeaveRoomInternal(); + + /// + /// Change the current settings. + /// + /// + /// A room must be joined for this to have any effect. + /// + /// The new room name, if any. + /// The new room playlist item, if any. + public Task ChangeSettings(Optional name = default, Optional item = default) + { + if (Room == null) + throw new InvalidOperationException("Must be joined to a match to change settings."); + + // A dummy playlist item filled with the current room settings (except mods). + var existingPlaylistItem = new PlaylistItem + { + Beatmap = + { + Value = new BeatmapInfo + { + OnlineBeatmapID = Room.Settings.BeatmapID, + MD5Hash = Room.Settings.BeatmapChecksum + } + }, + RulesetID = Room.Settings.RulesetID + }; + + return ChangeSettings(new MultiplayerRoomSettings + { + Name = name.GetOr(Room.Settings.Name), + BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, + BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, + RulesetID = item.GetOr(existingPlaylistItem).RulesetID, + RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods, + AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods, + }); + } + + /// + /// Toggles the 's ready state. + /// + /// If a toggle of ready state is not valid at this time. + public async Task ToggleReady() + { + var localUser = LocalUser; + + if (localUser == null) + return; + + switch (localUser.State) + { + case MultiplayerUserState.Idle: + await ChangeState(MultiplayerUserState.Ready).ConfigureAwait(false); + return; + + case MultiplayerUserState.Ready: + await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false); + return; + + default: + throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}"); + } + } + + /// + /// Toggles the 's spectating state. + /// + /// If a toggle of the spectating state is not valid at this time. + public async Task ToggleSpectate() + { + var localUser = LocalUser; + + if (localUser == null) + return; + + switch (localUser.State) + { + case MultiplayerUserState.Idle: + case MultiplayerUserState.Ready: + await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false); + return; + + case MultiplayerUserState.Spectating: + await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false); + return; + + default: + throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}"); + } + } + + public abstract Task TransferHost(int userId); + + public abstract Task ChangeSettings(MultiplayerRoomSettings settings); + + public abstract Task ChangeState(MultiplayerUserState newState); + + public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); + + /// + /// Change the local user's mods in the currently joined room. + /// + /// The proposed new mods, excluding any required by the room itself. + public Task ChangeUserMods(IEnumerable newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList()); + + public abstract Task ChangeUserMods(IEnumerable newMods); + + public abstract Task StartMatch(); + + Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) + { + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId); + Scheduler.Add(() => + { + if (Room == null) + return; + + Debug.Assert(apiRoom != null); + + Room.State = state; + + switch (state) + { + case MultiplayerRoomState.Open: + apiRoom.Status.Value = new RoomStatusOpen(); + break; + + case MultiplayerRoomState.Playing: + apiRoom.Status.Value = new RoomStatusPlaying(); + break; + + case MultiplayerRoomState.Closed: + apiRoom.Status.Value = new RoomStatusEnded(); + break; + } + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task ChangeSettings(MultiplayerRoomSettings settings) + async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) { - if (!IsConnected.Value) + if (Room == null) + return; + + await PopulateUser(user).ConfigureAwait(false); + + Scheduler.Add(() => + { + if (Room == null) + return; + + // for sanity, ensure that there can be no duplicate users in the room user list. + if (Room.Users.Any(existing => existing.UserID == user.UserID)) + return; + + Room.Users.Add(user); + + RoomUpdated?.Invoke(); + }, false); + } + + Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) + { + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings); + Scheduler.Add(() => + { + if (Room == null) + return; + + Room.Users.Remove(user); + CurrentMatchPlayingUserIds.Remove(user.UserID); + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task ChangeState(MultiplayerUserState newState) + Task IMultiplayerClient.HostChanged(int userId) { - if (!IsConnected.Value) + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState); + Scheduler.Add(() => + { + if (Room == null) + return; + + Debug.Assert(apiRoom != null); + + var user = Room.Users.FirstOrDefault(u => u.UserID == userId); + + Room.Host = user; + apiRoom.Host.Value = user?.User; + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability) + Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) { - if (!IsConnected.Value) + updateLocalRoomSettings(newSettings); + return Task.CompletedTask; + } + + Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state) + { + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability); + Scheduler.Add(() => + { + if (Room == null) + return; + + Room.Users.Single(u => u.UserID == userId).State = state; + + updateUserPlayingState(userId, state); + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task ChangeUserMods(IEnumerable newMods) + Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) { - if (!IsConnected.Value) + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods); + Scheduler.Add(() => + { + var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + + // errors here are not critical - beatmap availability state is mostly for display. + if (user == null) + return; + + user.BeatmapAvailability = beatmapAvailability; + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - public override Task StartMatch() + public Task UserModsChanged(int userId, IEnumerable mods) { - if (!IsConnected.Value) + if (Room == null) return Task.CompletedTask; - return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch)); + Scheduler.Add(() => + { + var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); + + // errors here are not critical - user mods are mostly for display. + if (user == null) + return; + + user.Mods = mods; + + RoomUpdated?.Invoke(); + }, false); + + return Task.CompletedTask; } - protected override void Dispose(bool isDisposing) + Task IMultiplayerClient.LoadRequested() { - base.Dispose(isDisposing); - connector?.Dispose(); + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + if (Room == null) + return; + + LoadRequested?.Invoke(); + }, false); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.MatchStarted() + { + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + if (Room == null) + return; + + MatchStarted?.Invoke(); + }, false); + + return Task.CompletedTask; + } + + Task IMultiplayerClient.ResultsReady() + { + if (Room == null) + return Task.CompletedTask; + + Scheduler.Add(() => + { + if (Room == null) + return; + + ResultsReady?.Invoke(); + }, false); + + return Task.CompletedTask; + } + + /// + /// Populates the for a given . + /// + /// The to populate. + protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID).ConfigureAwait(false); + + /// + /// Updates the local room settings with the given . + /// + /// + /// This updates both the joined and the respective API . + /// + /// The new to update from. + /// The to cancel the update. + private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() => + { + if (Room == null) + return; + + Debug.Assert(apiRoom != null); + + // Update a few properties of the room instantaneously. + Room.Settings = settings; + apiRoom.Name.Value = Room.Settings.Name; + + // The current item update is delayed until an online beatmap lookup (below) succeeds. + // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here. + CurrentMatchPlayingItem.Value = null; + + RoomUpdated?.Invoke(); + + GetOnlineBeatmapSet(settings.BeatmapID, cancellationToken).ContinueWith(set => Schedule(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + updatePlaylist(settings, set.Result); + }), TaskContinuationOptions.OnlyOnRanToCompletion); + }, cancellationToken); + + private void updatePlaylist(MultiplayerRoomSettings settings, BeatmapSetInfo beatmapSet) + { + if (Room == null || !Room.Settings.Equals(settings)) + return; + + Debug.Assert(apiRoom != null); + + var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); + beatmap.MD5Hash = settings.BeatmapChecksum; + + var ruleset = Rulesets.GetRuleset(settings.RulesetID).CreateInstance(); + var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset)); + var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset)); + + // Try to retrieve the existing playlist item from the API room. + var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); + + if (playlistItem != null) + updateItem(playlistItem); + else + { + // An existing playlist item does not exist, so append a new one. + updateItem(playlistItem = new PlaylistItem()); + apiRoom.Playlist.Add(playlistItem); + } + + CurrentMatchPlayingItem.Value = playlistItem; + + void updateItem(PlaylistItem item) + { + item.ID = settings.PlaylistItemId; + item.Beatmap.Value = beatmap; + item.Ruleset.Value = ruleset.RulesetInfo; + item.RequiredMods.Clear(); + item.RequiredMods.AddRange(mods); + item.AllowedMods.Clear(); + item.AllowedMods.AddRange(allowedMods); + } + } + + /// + /// Retrieves a from an online source. + /// + /// The beatmap set ID. + /// A token to cancel the request. + /// The retrieval task. + protected abstract Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default); + + /// + /// For the provided user ID, update whether the user is included in . + /// + /// The user's ID. + /// The new state of the user. + private void updateUserPlayingState(int userId, MultiplayerUserState state) + { + bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId); + bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay; + + if (isPlaying == wasPlaying) + return; + + if (isPlaying) + CurrentMatchPlayingUserIds.Add(userId); + else + CurrentMatchPlayingUserIds.Remove(userId); + } + + private Task scheduleAsync(Action action, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + + Scheduler.Add(() => + { + if (cancellationToken.IsCancellationRequested) + { + tcs.SetCanceled(); + return; + } + + try + { + action(); + tcs.SetResult(true); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + + return tcs.Task; } } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerUserState.cs b/osu.Game/Online/Multiplayer/MultiplayerUserState.cs index e54c71cd85..c467ff84bb 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerUserState.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerUserState.cs @@ -55,5 +55,10 @@ namespace osu.Game.Online.Multiplayer /// The user is currently viewing results. This is a reserved state, and is set by the server. /// Results, + + /// + /// The user is currently spectating this room. + /// + Spectating } } diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs new file mode 100644 index 0000000000..cf1e18e059 --- /dev/null +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -0,0 +1,158 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Rooms; + +namespace osu.Game.Online.Multiplayer +{ + /// + /// A with online connectivity. + /// + public class OnlineMultiplayerClient : MultiplayerClient + { + private readonly string endpoint; + + private IHubClientConnector? connector; + + public override IBindable IsConnected { get; } = new BindableBool(); + + private HubConnection? connection => connector?.CurrentConnection; + + public OnlineMultiplayerClient(EndpointConfiguration endpoints) + { + endpoint = endpoints.MultiplayerEndpointUrl; + } + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + connector = api.GetHubConnector(nameof(OnlineMultiplayerClient), endpoint); + + if (connector != null) + { + connector.ConfigureConnection = connection => + { + // this is kind of SILLY + // https://github.com/dotnet/aspnetcore/issues/15198 + connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged); + connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined); + connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft); + connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged); + connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged); + connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged); + connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested); + connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted); + connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady); + connection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged); + connection.On(nameof(IMultiplayerClient.UserBeatmapAvailabilityChanged), ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged); + }; + + IsConnected.BindTo(connector.IsConnected); + } + } + + protected override Task JoinRoom(long roomId) + { + if (!IsConnected.Value) + return Task.FromCanceled(new CancellationToken(true)); + + return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId); + } + + protected override Task LeaveRoomInternal() + { + if (!IsConnected.Value) + return Task.FromCanceled(new CancellationToken(true)); + + return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom)); + } + + public override Task TransferHost(int userId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.TransferHost), userId); + } + + public override Task ChangeSettings(MultiplayerRoomSettings settings) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeSettings), settings); + } + + public override Task ChangeState(MultiplayerUserState newState) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState); + } + + public override Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability); + } + + public override Task ChangeUserMods(IEnumerable newMods) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods); + } + + public override Task StartMatch() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch)); + } + + protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId); + + req.Success += res => + { + if (cancellationToken.IsCancellationRequested) + { + tcs.SetCanceled(); + return; + } + + tcs.SetResult(res.ToBeatmapSet(Rulesets)); + }; + + req.Failure += e => tcs.SetException(e); + + API.Queue(req); + + return tcs.Task; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + connector?.Dispose(); + } + } +} diff --git a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs deleted file mode 100644 index bfd505fb19..0000000000 --- a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs +++ /dev/null @@ -1,616 +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 enable - -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.Bindables; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Graphics; -using osu.Framework.Logging; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Rooms; -using osu.Game.Online.Rooms.RoomStatuses; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Users; -using osu.Game.Utils; - -namespace osu.Game.Online.Multiplayer -{ - public abstract class StatefulMultiplayerClient : Component, IMultiplayerClient, IMultiplayerRoomServer - { - /// - /// Invoked when any change occurs to the multiplayer room. - /// - public event Action? RoomUpdated; - - /// - /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. - /// - public event Action? LoadRequested; - - /// - /// Invoked when the multiplayer server requests gameplay to be started. - /// - public event Action? MatchStarted; - - /// - /// Invoked when the multiplayer server has finished collating results. - /// - public event Action? ResultsReady; - - /// - /// Whether the is currently connected. - /// This is NOT thread safe and usage should be scheduled. - /// - public abstract IBindable IsConnected { get; } - - /// - /// The joined . - /// - public MultiplayerRoom? Room { get; private set; } - - /// - /// The users in the joined which are participating in the current gameplay loop. - /// - public readonly BindableList CurrentMatchPlayingUserIds = new BindableList(); - - public readonly Bindable CurrentMatchPlayingItem = new Bindable(); - - /// - /// The corresponding to the local player, if available. - /// - public MultiplayerRoomUser? LocalUser => Room?.Users.SingleOrDefault(u => u.User?.Id == api.LocalUser.Value.Id); - - /// - /// Whether the is the host in . - /// - public bool IsHost - { - get - { - var localUser = LocalUser; - return localUser != null && Room?.Host != null && localUser.Equals(Room.Host); - } - } - - [Resolved] - private UserLookupCache userLookupCache { get; set; } = null!; - - [Resolved] - private IAPIProvider api { get; set; } = null!; - - [Resolved] - private RulesetStore rulesets { get; set; } = null!; - - // Only exists for compatibility with old osu-server-spectator build. - // Todo: Can be removed on 2021/02/26. - private long defaultPlaylistItemId; - - private Room? apiRoom; - - [BackgroundDependencyLoader] - private void load() - { - IsConnected.BindValueChanged(connected => - { - // clean up local room state on server disconnect. - if (!connected.NewValue && Room != null) - { - Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important); - LeaveRoom(); - } - }); - } - - private readonly TaskChain joinOrLeaveTaskChain = new TaskChain(); - private CancellationTokenSource? joinCancellationSource; - - /// - /// Joins the for a given API . - /// - /// The API . - public async Task JoinRoom(Room room) - { - var cancellationSource = joinCancellationSource = new CancellationTokenSource(); - - await joinOrLeaveTaskChain.Add(async () => - { - if (Room != null) - throw new InvalidOperationException("Cannot join a multiplayer room while already in one."); - - Debug.Assert(room.RoomID.Value != null); - - // Join the server-side room. - var joinedRoom = await JoinRoom(room.RoomID.Value.Value); - Debug.Assert(joinedRoom != null); - - // Populate users. - Debug.Assert(joinedRoom.Users != null); - await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)); - - // Update the stored room (must be done on update thread for thread-safety). - await scheduleAsync(() => - { - Room = joinedRoom; - apiRoom = room; - defaultPlaylistItemId = apiRoom.Playlist.FirstOrDefault()?.ID ?? 0; - }, cancellationSource.Token); - - // Update room settings. - await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token); - }, cancellationSource.Token); - } - - /// - /// Joins the with a given ID. - /// - /// The room ID. - /// The joined . - protected abstract Task JoinRoom(long roomId); - - public Task LeaveRoom() - { - // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled. - // This includes the setting of Room itself along with the initial update of the room settings on join. - joinCancellationSource?.Cancel(); - - // Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background. - // However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed. - // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time. - var scheduledReset = scheduleAsync(() => - { - apiRoom = null; - Room = null; - CurrentMatchPlayingUserIds.Clear(); - - RoomUpdated?.Invoke(); - }); - - return joinOrLeaveTaskChain.Add(async () => - { - await scheduledReset; - await LeaveRoomInternal(); - }); - } - - protected abstract Task LeaveRoomInternal(); - - /// - /// Change the current settings. - /// - /// - /// A room must be joined for this to have any effect. - /// - /// The new room name, if any. - /// The new room playlist item, if any. - public Task ChangeSettings(Optional name = default, Optional item = default) - { - if (Room == null) - throw new InvalidOperationException("Must be joined to a match to change settings."); - - // A dummy playlist item filled with the current room settings (except mods). - var existingPlaylistItem = new PlaylistItem - { - Beatmap = - { - Value = new BeatmapInfo - { - OnlineBeatmapID = Room.Settings.BeatmapID, - MD5Hash = Room.Settings.BeatmapChecksum - } - }, - RulesetID = Room.Settings.RulesetID - }; - - return ChangeSettings(new MultiplayerRoomSettings - { - Name = name.GetOr(Room.Settings.Name), - BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, - BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, - RulesetID = item.GetOr(existingPlaylistItem).RulesetID, - RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods, - AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods, - }); - } - - /// - /// Toggles the 's ready state. - /// - /// If a toggle of ready state is not valid at this time. - public async Task ToggleReady() - { - var localUser = LocalUser; - - if (localUser == null) - return; - - switch (localUser.State) - { - case MultiplayerUserState.Idle: - await ChangeState(MultiplayerUserState.Ready); - return; - - case MultiplayerUserState.Ready: - await ChangeState(MultiplayerUserState.Idle); - return; - - default: - throw new InvalidOperationException($"Cannot toggle ready when in {localUser.State}"); - } - } - - public abstract Task TransferHost(int userId); - - public abstract Task ChangeSettings(MultiplayerRoomSettings settings); - - public abstract Task ChangeState(MultiplayerUserState newState); - - public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability); - - /// - /// Change the local user's mods in the currently joined room. - /// - /// The proposed new mods, excluding any required by the room itself. - public Task ChangeUserMods(IEnumerable newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList()); - - public abstract Task ChangeUserMods(IEnumerable newMods); - - public abstract Task StartMatch(); - - Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - Debug.Assert(apiRoom != null); - - Room.State = state; - - switch (state) - { - case MultiplayerRoomState.Open: - apiRoom.Status.Value = new RoomStatusOpen(); - break; - - case MultiplayerRoomState.Playing: - apiRoom.Status.Value = new RoomStatusPlaying(); - break; - - case MultiplayerRoomState.Closed: - apiRoom.Status.Value = new RoomStatusEnded(); - break; - } - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - async Task IMultiplayerClient.UserJoined(MultiplayerRoomUser user) - { - if (Room == null) - return; - - await PopulateUser(user); - - Scheduler.Add(() => - { - if (Room == null) - return; - - // for sanity, ensure that there can be no duplicate users in the room user list. - if (Room.Users.Any(existing => existing.UserID == user.UserID)) - return; - - Room.Users.Add(user); - - RoomUpdated?.Invoke(); - }, false); - } - - Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - Room.Users.Remove(user); - CurrentMatchPlayingUserIds.Remove(user.UserID); - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.HostChanged(int userId) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - Debug.Assert(apiRoom != null); - - var user = Room.Users.FirstOrDefault(u => u.UserID == userId); - - Room.Host = user; - apiRoom.Host.Value = user?.User; - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) - { - updateLocalRoomSettings(newSettings); - return Task.CompletedTask; - } - - Task IMultiplayerClient.UserStateChanged(int userId, MultiplayerUserState state) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - Room.Users.Single(u => u.UserID == userId).State = state; - - updateUserPlayingState(userId, state); - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); - - // errors here are not critical - beatmap availability state is mostly for display. - if (user == null) - return; - - user.BeatmapAvailability = beatmapAvailability; - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - public Task UserModsChanged(int userId, IEnumerable mods) - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - var user = Room?.Users.SingleOrDefault(u => u.UserID == userId); - - // errors here are not critical - user mods are mostly for display. - if (user == null) - return; - - user.Mods = mods; - - RoomUpdated?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.LoadRequested() - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - LoadRequested?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.MatchStarted() - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - MatchStarted?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - Task IMultiplayerClient.ResultsReady() - { - if (Room == null) - return Task.CompletedTask; - - Scheduler.Add(() => - { - if (Room == null) - return; - - ResultsReady?.Invoke(); - }, false); - - return Task.CompletedTask; - } - - /// - /// Populates the for a given . - /// - /// The to populate. - protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID); - - /// - /// Updates the local room settings with the given . - /// - /// - /// This updates both the joined and the respective API . - /// - /// The new to update from. - /// The to cancel the update. - private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() => - { - if (Room == null) - return; - - Debug.Assert(apiRoom != null); - - // Update a few properties of the room instantaneously. - Room.Settings = settings; - apiRoom.Name.Value = Room.Settings.Name; - - // The current item update is delayed until an online beatmap lookup (below) succeeds. - // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here. - CurrentMatchPlayingItem.Value = null; - - RoomUpdated?.Invoke(); - - var req = new GetBeatmapSetRequest(settings.BeatmapID, BeatmapSetLookupType.BeatmapId); - req.Success += res => - { - if (cancellationToken.IsCancellationRequested) - return; - - updatePlaylist(settings, res); - }; - - api.Queue(req); - }, cancellationToken); - - private void updatePlaylist(MultiplayerRoomSettings settings, APIBeatmapSet onlineSet) - { - if (Room == null || !Room.Settings.Equals(settings)) - return; - - Debug.Assert(apiRoom != null); - - var beatmapSet = onlineSet.ToBeatmapSet(rulesets); - var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); - beatmap.MD5Hash = settings.BeatmapChecksum; - - var ruleset = rulesets.GetRuleset(settings.RulesetID).CreateInstance(); - var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset)); - var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset)); - - // Try to retrieve the existing playlist item from the API room. - var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); - - if (playlistItem != null) - updateItem(playlistItem); - else - { - // An existing playlist item does not exist, so append a new one. - updateItem(playlistItem = new PlaylistItem()); - apiRoom.Playlist.Add(playlistItem); - } - - CurrentMatchPlayingItem.Value = playlistItem; - - void updateItem(PlaylistItem item) - { - item.ID = settings.PlaylistItemId == 0 ? defaultPlaylistItemId : settings.PlaylistItemId; - item.Beatmap.Value = beatmap; - item.Ruleset.Value = ruleset.RulesetInfo; - item.RequiredMods.Clear(); - item.RequiredMods.AddRange(mods); - item.AllowedMods.Clear(); - item.AllowedMods.AddRange(allowedMods); - } - } - - /// - /// For the provided user ID, update whether the user is included in . - /// - /// The user's ID. - /// The new state of the user. - private void updateUserPlayingState(int userId, MultiplayerUserState state) - { - bool wasPlaying = CurrentMatchPlayingUserIds.Contains(userId); - bool isPlaying = state >= MultiplayerUserState.WaitingForLoad && state <= MultiplayerUserState.FinishedPlay; - - if (isPlaying == wasPlaying) - return; - - if (isPlaying) - CurrentMatchPlayingUserIds.Add(userId); - else - CurrentMatchPlayingUserIds.Remove(userId); - } - - private Task scheduleAsync(Action action, CancellationToken cancellationToken = default) - { - var tcs = new TaskCompletionSource(); - - Scheduler.Add(() => - { - if (cancellationToken.IsCancellationRequested) - { - tcs.SetCanceled(); - return; - } - - try - { - action(); - tcs.SetResult(true); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - }); - - return tcs.Task; - } - } -} diff --git a/osu.Game/Online/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs index 8868f90524..4955aa9058 100644 --- a/osu.Game/Online/OnlineViewContainer.cs +++ b/osu.Game/Online/OnlineViewContainer.cs @@ -23,13 +23,17 @@ namespace osu.Game.Online private readonly string placeholderMessage; - private Placeholder placeholder; + private Drawable placeholder; private const double transform_duration = 300; [Resolved] protected IAPIProvider API { get; private set; } + /// + /// Construct a new instance of an online view container. + /// + /// The message to display when not logged in. If empty, no button will display. public OnlineViewContainer(string placeholderMessage) { this.placeholderMessage = placeholderMessage; @@ -40,10 +44,10 @@ namespace osu.Game.Online [BackgroundDependencyLoader] private void load(IAPIProvider api) { - InternalChildren = new Drawable[] + InternalChildren = new[] { Content, - placeholder = new LoginPlaceholder(placeholderMessage), + placeholder = string.IsNullOrEmpty(placeholderMessage) ? Empty() : new LoginPlaceholder(placeholderMessage), LoadingSpinner = new LoadingSpinner { Alpha = 0, diff --git a/osu.Game/Online/Rooms/APIScoreToken.cs b/osu.Game/Online/Rooms/APIScoreToken.cs index f652c1720d..6b559876de 100644 --- a/osu.Game/Online/Rooms/APIScoreToken.cs +++ b/osu.Game/Online/Rooms/APIScoreToken.cs @@ -8,6 +8,6 @@ namespace osu.Game.Online.Rooms public class APIScoreToken { [JsonProperty("id")] - public int ID { get; set; } + public long ID { get; set; } } } diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs similarity index 95% rename from osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs rename to osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index d6f4c45a75..72ea84d4a8 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -16,7 +16,7 @@ namespace osu.Game.Online.Rooms /// This differs from a regular download tracking composite as this accounts for the /// databased beatmap set's checksum, to disallow from playing with an altered version of the beatmap. /// - public class OnlinePlayBeatmapAvailablilityTracker : DownloadTrackingComposite + public class OnlinePlayBeatmapAvailabilityTracker : DownloadTrackingComposite { public readonly IBindable SelectedItem = new Bindable(); @@ -45,6 +45,9 @@ namespace osu.Game.Online.Rooms Progress.BindValueChanged(_ => { + if (State.Value != DownloadState.Downloading) + return; + // incoming progress changes are going to be at a very high rate. // we don't want to flood the network with this, so rate limit how often we send progress updates. if (progressUpdate?.Completed != false) diff --git a/osu.Game/Online/Solo/CreateSoloScoreRequest.cs b/osu.Game/Online/Solo/CreateSoloScoreRequest.cs new file mode 100644 index 0000000000..0d2bea1f2a --- /dev/null +++ b/osu.Game/Online/Solo/CreateSoloScoreRequest.cs @@ -0,0 +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.Globalization; +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; + +namespace osu.Game.Online.Solo +{ + public class CreateSoloScoreRequest : APIRequest + { + private readonly int beatmapId; + private readonly int rulesetId; + private readonly string versionHash; + + public CreateSoloScoreRequest(int beatmapId, int rulesetId, string versionHash) + { + this.beatmapId = beatmapId; + this.rulesetId = rulesetId; + this.versionHash = versionHash; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + req.AddParameter("version_hash", versionHash); + req.AddParameter("ruleset_id", rulesetId.ToString(CultureInfo.InvariantCulture)); + return req; + } + + protected override string Target => $@"beatmaps/{beatmapId}/solo/scores"; + } +} diff --git a/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs new file mode 100644 index 0000000000..85fa3eeb34 --- /dev/null +++ b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using Newtonsoft.Json; +using osu.Framework.IO.Network; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Scoring; + +namespace osu.Game.Online.Solo +{ + public class SubmitSoloScoreRequest : APIRequest + { + private readonly long scoreId; + + private readonly int beatmapId; + + private readonly ScoreInfo scoreInfo; + + public SubmitSoloScoreRequest(int beatmapId, long scoreId, ScoreInfo scoreInfo) + { + this.beatmapId = beatmapId; + this.scoreId = scoreId; + this.scoreInfo = scoreInfo; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.ContentType = "application/json"; + req.Method = HttpMethod.Put; + + req.AddRaw(JsonConvert.SerializeObject(scoreInfo, new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + })); + + return req; + } + + protected override string Target => $@"beatmaps/{beatmapId}/solo/scores/{scoreId}"; + } +} diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs new file mode 100644 index 0000000000..753796158e --- /dev/null +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Online.API; + +namespace osu.Game.Online.Spectator +{ + public class OnlineSpectatorClient : SpectatorClient + { + private readonly string endpoint; + + private IHubClientConnector? connector; + + public override IBindable IsConnected { get; } = new BindableBool(); + + private HubConnection? connection => connector?.CurrentConnection; + + public OnlineSpectatorClient(EndpointConfiguration endpoints) + { + endpoint = endpoints.SpectatorEndpointUrl; + } + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + connector = api.GetHubConnector(nameof(SpectatorClient), endpoint); + + if (connector != null) + { + connector.ConfigureConnection = connection => + { + // until strong typed client support is added, each method must be manually bound + // (see https://github.com/dotnet/aspnetcore/issues/15198) + connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); + connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); + connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); + }; + + IsConnected.BindTo(connector.IsConnected); + } + } + + protected override Task BeginPlayingInternal(SpectatorState state) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), state); + } + + protected override Task SendFramesInternal(FrameDataBundle data) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data); + } + + protected override Task EndPlayingInternal(SpectatorState state) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), state); + } + + protected override Task WatchUserInternal(int userId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); + } + + protected override Task StopWatchingUserInternal(int userId) + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId); + } + } +} diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs new file mode 100644 index 0000000000..2546374b21 --- /dev/null +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -0,0 +1,264 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Development; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Replays.Legacy; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Online.Spectator +{ + public abstract class SpectatorClient : Component, ISpectatorClient + { + /// + /// The maximum milliseconds between frame bundle sends. + /// + public const double TIME_BETWEEN_SENDS = 200; + + /// + /// Whether the is currently connected. + /// This is NOT thread safe and usage should be scheduled. + /// + public abstract IBindable IsConnected { get; } + + private readonly List watchingUsers = new List(); + + public IBindableList PlayingUsers => playingUsers; + private readonly BindableList playingUsers = new BindableList(); + + public IBindableDictionary PlayingUserStates => playingUserStates; + private readonly BindableDictionary playingUserStates = new BindableDictionary(); + + private IBeatmap? currentBeatmap; + + private Score? currentScore; + + [Resolved] + private IBindable currentRuleset { get; set; } = null!; + + [Resolved] + private IBindable> currentMods { get; set; } = null!; + + private readonly SpectatorState currentState = new SpectatorState(); + + /// + /// Whether the local user is playing. + /// + protected bool IsPlaying { get; private set; } + + /// + /// Called whenever new frames arrive from the server. + /// + public event Action? OnNewFrames; + + /// + /// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session. + /// + public event Action? OnUserBeganPlaying; + + /// + /// Called whenever a user finishes a play session. + /// + public event Action? OnUserFinishedPlaying; + + [BackgroundDependencyLoader] + private void load() + { + IsConnected.BindValueChanged(connected => Schedule(() => + { + if (connected.NewValue) + { + // get all the users that were previously being watched + int[] users = watchingUsers.ToArray(); + watchingUsers.Clear(); + + // resubscribe to watched users. + foreach (var userId in users) + WatchUser(userId); + + // re-send state in case it wasn't received + if (IsPlaying) + BeginPlayingInternal(currentState); + } + else + { + playingUsers.Clear(); + playingUserStates.Clear(); + } + }), true); + } + + Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state) + { + Schedule(() => + { + if (!playingUsers.Contains(userId)) + playingUsers.Add(userId); + + // UserBeganPlaying() is called by the server regardless of whether the local user is watching the remote user, and is called a further time when the remote user is watched. + // This may be a temporary thing (see: https://github.com/ppy/osu-server-spectator/blob/2273778e02cfdb4a9c6a934f2a46a8459cb5d29c/osu.Server.Spectator/Hubs/SpectatorHub.cs#L28-L29). + // We don't want the user states to update unless the player is being watched, otherwise calling BindUserBeganPlaying() can lead to double invocations. + if (watchingUsers.Contains(userId)) + playingUserStates[userId] = state; + + OnUserBeganPlaying?.Invoke(userId, state); + }); + + return Task.CompletedTask; + } + + Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state) + { + Schedule(() => + { + playingUsers.Remove(userId); + playingUserStates.Remove(userId); + + OnUserFinishedPlaying?.Invoke(userId, state); + }); + + return Task.CompletedTask; + } + + Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data) + { + Schedule(() => OnNewFrames?.Invoke(userId, data)); + + return Task.CompletedTask; + } + + public void BeginPlaying(GameplayBeatmap beatmap, Score score) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + if (IsPlaying) + throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); + + IsPlaying = true; + + // transfer state at point of beginning play + currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID; + currentState.RulesetID = currentRuleset.Value.ID; + currentState.Mods = currentMods.Value.Select(m => new APIMod(m)); + + currentBeatmap = beatmap.PlayableBeatmap; + currentScore = score; + + BeginPlayingInternal(currentState); + } + + public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data); + + public void EndPlaying() + { + // This method is most commonly called via Dispose(), which is asynchronous. + // Todo: This should not be a thing, but requires framework changes. + Schedule(() => + { + if (!IsPlaying) + return; + + IsPlaying = false; + currentBeatmap = null; + + EndPlayingInternal(currentState); + }); + } + + public void WatchUser(int userId) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + if (watchingUsers.Contains(userId)) + return; + + watchingUsers.Add(userId); + + WatchUserInternal(userId); + } + + public void StopWatchingUser(int userId) + { + // This method is most commonly called via Dispose(), which is asynchronous. + // Todo: This should not be a thing, but requires framework changes. + Schedule(() => + { + watchingUsers.Remove(userId); + playingUserStates.Remove(userId); + StopWatchingUserInternal(userId); + }); + } + + protected abstract Task BeginPlayingInternal(SpectatorState state); + + protected abstract Task SendFramesInternal(FrameDataBundle data); + + protected abstract Task EndPlayingInternal(SpectatorState state); + + protected abstract Task WatchUserInternal(int userId); + + protected abstract Task StopWatchingUserInternal(int userId); + + private readonly Queue pendingFrames = new Queue(); + + private double lastSendTime; + + private Task? lastSend; + + private const int max_pending_frames = 30; + + protected override void Update() + { + base.Update(); + + if (pendingFrames.Count > 0 && Time.Current - lastSendTime > TIME_BETWEEN_SENDS) + purgePendingFrames(); + } + + public void HandleFrame(ReplayFrame frame) + { + Debug.Assert(ThreadSafety.IsUpdateThread); + + if (!IsPlaying) + return; + + if (frame is IConvertibleReplayFrame convertible) + pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap)); + + if (pendingFrames.Count > max_pending_frames) + purgePendingFrames(); + } + + private void purgePendingFrames() + { + if (lastSend?.IsCompleted == false) + return; + + var frames = pendingFrames.ToArray(); + + pendingFrames.Clear(); + + Debug.Assert(currentScore != null); + + SendFrames(new FrameDataBundle(currentScore.ScoreInfo, frames)); + + lastSendTime = Time.Current; + } + } +} diff --git a/osu.Game/Online/Spectator/SpectatorState.cs b/osu.Game/Online/Spectator/SpectatorState.cs index 96a875bc14..ebb91e4dd2 100644 --- a/osu.Game/Online/Spectator/SpectatorState.cs +++ b/osu.Game/Online/Spectator/SpectatorState.cs @@ -24,7 +24,13 @@ namespace osu.Game.Online.Spectator [Key(2)] public IEnumerable Mods { get; set; } = Enumerable.Empty(); - public bool Equals(SpectatorState other) => BeatmapID == other?.BeatmapID && Mods.SequenceEqual(other?.Mods) && RulesetID == other?.RulesetID; + public bool Equals(SpectatorState other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return BeatmapID == other.BeatmapID && Mods.SequenceEqual(other.Mods) && RulesetID == other.RulesetID; + } public override string ToString() => $"Beatmap:{BeatmapID} Mods:{string.Join(',', Mods)} Ruleset:{RulesetID}"; } diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs deleted file mode 100644 index 3a586874fe..0000000000 --- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs +++ /dev/null @@ -1,272 +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.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using JetBrains.Annotations; -using Microsoft.AspNetCore.SignalR.Client; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Replays.Legacy; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Replays; -using osu.Game.Rulesets.Replays.Types; -using osu.Game.Scoring; -using osu.Game.Screens.Play; - -namespace osu.Game.Online.Spectator -{ - public class SpectatorStreamingClient : Component, ISpectatorClient - { - /// - /// The maximum milliseconds between frame bundle sends. - /// - public const double TIME_BETWEEN_SENDS = 200; - - private readonly string endpoint; - - [CanBeNull] - private IHubClientConnector connector; - - private readonly IBindable isConnected = new BindableBool(); - - private HubConnection connection => connector?.CurrentConnection; - - private readonly List watchingUsers = new List(); - - private readonly object userLock = new object(); - - public IBindableList PlayingUsers => playingUsers; - - private readonly BindableList playingUsers = new BindableList(); - - [CanBeNull] - private IBeatmap currentBeatmap; - - [CanBeNull] - private Score currentScore; - - [Resolved] - private IBindable currentRuleset { get; set; } - - [Resolved] - private IBindable> currentMods { get; set; } - - private readonly SpectatorState currentState = new SpectatorState(); - - private bool isPlaying; - - /// - /// Called whenever new frames arrive from the server. - /// - public event Action OnNewFrames; - - /// - /// Called whenever a user starts a play session. - /// - public event Action OnUserBeganPlaying; - - /// - /// Called whenever a user finishes a play session. - /// - public event Action OnUserFinishedPlaying; - - public SpectatorStreamingClient(EndpointConfiguration endpoints) - { - endpoint = endpoints.SpectatorEndpointUrl; - } - - [BackgroundDependencyLoader] - private void load(IAPIProvider api) - { - connector = api.GetHubConnector(nameof(SpectatorStreamingClient), endpoint); - - if (connector != null) - { - connector.ConfigureConnection = connection => - { - // until strong typed client support is added, each method must be manually bound - // (see https://github.com/dotnet/aspnetcore/issues/15198) - connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); - connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); - connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); - }; - - isConnected.BindTo(connector.IsConnected); - isConnected.BindValueChanged(connected => - { - if (connected.NewValue) - { - // get all the users that were previously being watched - int[] users; - - lock (userLock) - { - users = watchingUsers.ToArray(); - watchingUsers.Clear(); - } - - // resubscribe to watched users. - foreach (var userId in users) - WatchUser(userId); - - // re-send state in case it wasn't received - if (isPlaying) - beginPlaying(); - } - else - { - playingUsers.Clear(); - } - }, true); - } - } - - Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state) - { - if (!playingUsers.Contains(userId)) - playingUsers.Add(userId); - - OnUserBeganPlaying?.Invoke(userId, state); - - return Task.CompletedTask; - } - - Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state) - { - playingUsers.Remove(userId); - - OnUserFinishedPlaying?.Invoke(userId, state); - - return Task.CompletedTask; - } - - Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data) - { - OnNewFrames?.Invoke(userId, data); - - return Task.CompletedTask; - } - - public void BeginPlaying(GameplayBeatmap beatmap, Score score) - { - if (isPlaying) - throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); - - isPlaying = true; - - // transfer state at point of beginning play - currentState.BeatmapID = beatmap.BeatmapInfo.OnlineBeatmapID; - currentState.RulesetID = currentRuleset.Value.ID; - currentState.Mods = currentMods.Value.Select(m => new APIMod(m)); - - currentBeatmap = beatmap.PlayableBeatmap; - currentScore = score; - - beginPlaying(); - } - - private void beginPlaying() - { - Debug.Assert(isPlaying); - - if (!isConnected.Value) return; - - connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState); - } - - public void SendFrames(FrameDataBundle data) - { - if (!isConnected.Value) return; - - lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data); - } - - public void EndPlaying() - { - isPlaying = false; - currentBeatmap = null; - - if (!isConnected.Value) return; - - connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState); - } - - public virtual void WatchUser(int userId) - { - lock (userLock) - { - if (watchingUsers.Contains(userId)) - return; - - watchingUsers.Add(userId); - - if (!isConnected.Value) - return; - } - - connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); - } - - public void StopWatchingUser(int userId) - { - lock (userLock) - { - watchingUsers.Remove(userId); - - if (!isConnected.Value) - return; - } - - connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId); - } - - private readonly Queue pendingFrames = new Queue(); - - private double lastSendTime; - - private Task lastSend; - - private const int max_pending_frames = 30; - - protected override void Update() - { - base.Update(); - - if (pendingFrames.Count > 0 && Time.Current - lastSendTime > TIME_BETWEEN_SENDS) - purgePendingFrames(); - } - - public void HandleFrame(ReplayFrame frame) - { - if (frame is IConvertibleReplayFrame convertible) - pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap)); - - if (pendingFrames.Count > max_pending_frames) - purgePendingFrames(); - } - - private void purgePendingFrames() - { - if (lastSend?.IsCompleted == false) - return; - - var frames = pendingFrames.ToArray(); - - pendingFrames.Clear(); - - Debug.Assert(currentScore != null); - - SendFrames(new FrameDataBundle(currentScore.ScoreInfo, frames)); - - lastSendTime = Time.Current; - } - } -} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 771bcd2310..c51624341e 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -27,7 +27,6 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Collections; @@ -52,6 +51,8 @@ using osu.Game.Utils; using LogLevel = osu.Framework.Logging.LogLevel; using osu.Game.Database; using osu.Game.IO; +using osu.Game.Localisation; +using osu.Game.Skinning.Editor; namespace osu.Game { @@ -80,9 +81,31 @@ namespace osu.Game private BeatmapSetOverlay beatmapSetOverlay; + private WikiOverlay wikiOverlay; + + private SkinEditorOverlay skinEditor; + + private Container overlayContent; + + private Container rightFloatingOverlayContent; + + private Container leftFloatingOverlayContent; + + private Container topMostOverlayContent; + + private ScalingContainer screenContainer; + + private Container screenOffsetContainer; + + [Resolved] + private FrameworkConfigManager frameworkConfig { get; set; } + [Cached] private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender(); + [Cached] + private readonly StableImportManager stableImportManager = new StableImportManager(); + [Cached] private readonly ScreenshotManager screenshotManager = new ScreenshotManager(); @@ -254,7 +277,7 @@ namespace osu.Game { case LinkAction.OpenBeatmap: // TODO: proper query params handling - if (link.Argument != null && int.TryParse(link.Argument.Contains('?') ? link.Argument.Split('?')[0] : link.Argument, out int beatmapId)) + if (int.TryParse(link.Argument.Contains('?') ? link.Argument.Split('?')[0] : link.Argument, out int beatmapId)) ShowBeatmap(beatmapId); break; @@ -286,6 +309,10 @@ namespace osu.Game ShowUser(userId); break; + case LinkAction.OpenWiki: + ShowWiki(link.Argument); + break; + default: throw new NotImplementedException($"This {nameof(LinkAction)} ({link.Action.ToString()}) is missing an associated action."); } @@ -333,6 +360,12 @@ namespace osu.Game /// The beatmap to show. public void ShowBeatmap(int beatmapId) => waitForReady(() => beatmapSetOverlay, _ => beatmapSetOverlay.FetchAndShowBeatmap(beatmapId)); + /// + /// Show a wiki's page as an overlay + /// + /// The wiki page to show + public void ShowWiki(string path) => waitForReady(() => wikiOverlay, _ => wikiOverlay.ShowPage(path)); + /// /// Present a beatmap at song select immediately. /// The user should have already requested this interactively. @@ -361,14 +394,6 @@ namespace osu.Game PerformFromScreen(screen => { - // we might already be at song select, so a check is required before performing the load to solo. - if (screen is MainMenu) - menuScreen.LoadToSolo(); - - // we might even already be at the song - if (Beatmap.Value.BeatmapSetInfo.Hash == databasedSet.Hash && (difficultyCriteria?.Invoke(Beatmap.Value.BeatmapInfo) ?? true)) - return; - // Find beatmaps that match our predicate. var beatmaps = databasedSet.Beatmaps.Where(b => difficultyCriteria?.Invoke(b) ?? true).ToList(); @@ -381,9 +406,16 @@ namespace osu.Game ?? beatmaps.FirstOrDefault(b => b.Ruleset.Equals(Ruleset.Value)) ?? beatmaps.First(); - Ruleset.Value = selection.Ruleset; - Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection); - }, validScreens: new[] { typeof(SongSelect) }); + if (screen is IHandlePresentBeatmap presentableScreen) + { + presentableScreen.PresentBeatmap(BeatmapManager.GetWorkingBeatmap(selection), selection.Ruleset); + } + else + { + Ruleset.Value = selection.Ruleset; + Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection); + } + }, validScreens: new[] { typeof(SongSelect), typeof(IHandlePresentBeatmap) }); } /// @@ -441,7 +473,7 @@ namespace osu.Game public override Task Import(params ImportTask[] imports) { // encapsulate task as we don't want to begin the import process until in a ready state. - var importTask = new Task(async () => await base.Import(imports)); + var importTask = new Task(async () => await base.Import(imports).ConfigureAwait(false)); waitForReady(() => this, _ => importTask.Start()); @@ -532,10 +564,23 @@ namespace osu.Game SentryLogger.Dispose(); } + protected override IDictionary GetFrameworkConfigDefaults() + => new Dictionary + { + // General expectation that osu! starts in fullscreen by default (also gives the most predictable performance) + { FrameworkSetting.WindowMode, WindowMode.Fullscreen } + }; + protected override void LoadComplete() { base.LoadComplete(); + foreach (var language in Enum.GetValues(typeof(Language)).OfType()) + { + var cultureCode = language.ToString(); + Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode)); + } + // The next time this is updated is in UpdateAfterChildren, which occurs too late and results // in the cursor being shown for a few frames during the intro. // This prevents the cursor from showing until we have a screen with CursorVisible = true @@ -543,14 +588,11 @@ namespace osu.Game // todo: all archive managers should be able to be looped here. SkinManager.PostNotification = n => notifications.Post(n); - SkinManager.GetStableStorage = GetStorageForStableInstall; BeatmapManager.PostNotification = n => notifications.Post(n); - BeatmapManager.GetStableStorage = GetStorageForStableInstall; BeatmapManager.PresentImport = items => PresentBeatmap(items.First()); ScoreManager.PostNotification = n => notifications.Post(n); - ScoreManager.GetStableStorage = GetStorageForStableInstall; ScoreManager.PresentImport = items => PresentScore(items.First()); // make config aware of how to lookup skins for on-screen display purposes. @@ -572,6 +614,15 @@ namespace osu.Game dependencies.CacheAs(idleTracker = new GameIdleTracker(6000)); + var sessionIdleTracker = new GameIdleTracker(300000); + sessionIdleTracker.IsIdle.BindValueChanged(idle => + { + if (idle.NewValue) + SessionStatics.ResetValues(); + }); + + Add(sessionIdleTracker); + AddRange(new Drawable[] { new VolumeControlReceptor @@ -580,26 +631,35 @@ namespace osu.Game ActionRequested = action => volume.Adjust(action), ScrollActionRequested = (action, amount, isPrecise) => volume.Adjust(action, amount, isPrecise), }, - screenContainer = new ScalingContainer(ScalingMode.ExcludeOverlays) + screenOffsetContainer = new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - receptor = new BackButton.Receptor(), - ScreenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, - BackButton = new BackButton(receptor) + screenContainer = new ScalingContainer(ScalingMode.ExcludeOverlays) { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Action = () => + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] { - var currentScreen = ScreenStack.CurrentScreen as IOsuScreen; + receptor = new BackButton.Receptor(), + ScreenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, + BackButton = new BackButton(receptor) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Action = () => + { + var currentScreen = ScreenStack.CurrentScreen as IOsuScreen; - if (currentScreen?.AllowBackButton == true && !currentScreen.OnBackButton()) - ScreenStack.Exit(); + if (currentScreen?.AllowBackButton == true && !currentScreen.OnBackButton()) + ScreenStack.Exit(); + } + }, + logoContainer = new Container { RelativeSizeAxes = Axes.Both }, } }, - logoContainer = new Container { RelativeSizeAxes = Axes.Both }, } }, overlayContent = new Container { RelativeSizeAxes = Axes.Both }, @@ -649,10 +709,10 @@ namespace osu.Game loadComponentSingleFile(new CollectionManager(Storage) { PostNotification = n => notifications.Post(n), - GetStableStorage = GetStorageForStableInstall }, Add, true); loadComponentSingleFile(difficultyRecommender, Add); + loadComponentSingleFile(stableImportManager, Add); loadComponentSingleFile(screenshotManager, Add); @@ -671,6 +731,8 @@ namespace osu.Game var changelogOverlay = loadComponentSingleFile(new ChangelogOverlay(), overlayContent.Add, true); loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); + loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); + loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add); loadComponentSingleFile(new LoginOverlay { @@ -720,7 +782,7 @@ namespace osu.Game } // ensure only one of these overlays are open at once. - var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay }; + var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay, wikiOverlay }; foreach (var overlay in singleDisplayOverlays) { @@ -748,7 +810,7 @@ namespace osu.Game if (notifications.State.Value == Visibility.Visible) offset -= Toolbar.HEIGHT / 2; - screenContainer.MoveToX(offset, SettingsPanel.TRANSITION_LENGTH, Easing.OutQuint); + screenOffsetContainer.MoveToX(offset, SettingsPanel.TRANSITION_LENGTH, Easing.OutQuint); } Settings.State.ValueChanged += _ => updateScreenOffset(); @@ -759,9 +821,15 @@ namespace osu.Game { otherOverlays.Where(o => o != overlay).ForEach(o => o.Hide()); - // show above others if not visible at all, else leave at current depth. - if (!overlay.IsPresent) + // Partially visible so leave it at the current depth. + if (overlay.IsPresent) + return; + + // Show above all other overlays. + if (overlay.IsLoaded) overlayContent.ChangeChildDepth(overlay, (float)-Clock.CurrentTime); + else + overlay.Depth = (float)-Clock.CurrentTime; } private void forwardLoggedErrorsToNotifications() @@ -832,7 +900,7 @@ namespace osu.Game asyncLoadStream = Task.Run(async () => { if (previousLoadStream != null) - await previousLoadStream; + await previousLoadStream.ConfigureAwait(false); try { @@ -846,7 +914,7 @@ namespace osu.Game // The delegate won't complete if OsuGame has been disposed in the meantime while (!IsDisposed && !del.Completed) - await Task.Delay(10); + await Task.Delay(10).ConfigureAwait(false); // Either we're disposed or the load process has started successfully if (IsDisposed) @@ -854,7 +922,7 @@ namespace osu.Game Debug.Assert(task != null); - await task; + await task.ConfigureAwait(false); Logger.Log($"Loaded {component}!", level: LogLevel.Debug); } @@ -867,13 +935,6 @@ namespace osu.Game return component; } - protected override bool OnScroll(ScrollEvent e) - { - // forward any unhandled mouse scroll events to the volume control. - volume.Adjust(GlobalAction.IncreaseVolume, e.ScrollDelta.Y, e.IsPrecise); - return true; - } - public bool OnPressed(GlobalAction action) { if (introScreen == null) return false; @@ -881,22 +942,13 @@ namespace osu.Game switch (action) { case GlobalAction.ResetInputSettings: - var sensitivity = frameworkConfig.GetBindable(FrameworkSetting.CursorSensitivity); - - sensitivity.Disabled = false; - sensitivity.Value = 1; - sensitivity.Disabled = true; - - frameworkConfig.Set(FrameworkSetting.IgnoredInputHandlers, string.Empty); + Host.ResetInputHandlers(); frameworkConfig.GetBindable(FrameworkSetting.ConfineMouseMode).SetDefault(); return true; - case GlobalAction.ToggleToolbar: - Toolbar.ToggleVisibility(); - return true; - case GlobalAction.ToggleGameplayMouseButtons: - LocalConfig.Set(OsuSetting.MouseDisableButtons, !LocalConfig.Get(OsuSetting.MouseDisableButtons)); + var mouseDisableButtons = LocalConfig.GetBindable(OsuSetting.MouseDisableButtons); + mouseDisableButtons.Value = !mouseDisableButtons.Value; return true; case GlobalAction.RandomSkin: @@ -925,19 +977,6 @@ namespace osu.Game { } - private Container overlayContent; - - private Container rightFloatingOverlayContent; - - private Container leftFloatingOverlayContent; - - private Container topMostOverlayContent; - - [Resolved] - private FrameworkConfigManager frameworkConfig { get; set; } - - private ScalingContainer screenContainer; - protected override bool OnExiting() { if (ScreenStack.CurrentScreen is Loader) @@ -956,7 +995,7 @@ namespace osu.Game { base.UpdateAfterChildren(); - screenContainer.Padding = new MarginPadding { Top = ToolbarOffset }; + screenOffsetContainer.Padding = new MarginPadding { Top = ToolbarOffset }; overlayContent.Padding = new MarginPadding { Top = ToolbarOffset }; MenuCursorContainer.CanShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; @@ -964,6 +1003,8 @@ namespace osu.Game protected virtual void ScreenChanged(IScreen current, IScreen newScreen) { + skinEditor.Reset(); + switch (newScreen) { case IntroScreen intro: diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 3d24f245f9..7935815f38 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -40,7 +39,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Skinning; -using osuTK.Input; +using osu.Game.Utils; using RuntimeInfo = osu.Framework.RuntimeInfo; namespace osu.Game @@ -50,76 +49,19 @@ namespace osu.Game /// Unlike , this class will not load any kind of UI, allowing it to be used /// for provide dependencies to test cases without interfering with them. /// - public class OsuGameBase : Framework.Game, ICanAcceptFiles + public partial class OsuGameBase : Framework.Game, ICanAcceptFiles { - public const string CLIENT_STREAM_NAME = "lazer"; + public const string CLIENT_STREAM_NAME = @"lazer"; public const int SAMPLE_CONCURRENCY = 6; + /// + /// The maximum volume at which audio tracks should playback. This can be set lower than 1 to create some head-room for sound effects. + /// + internal const double GLOBAL_TRACK_VOLUME_ADJUST = 0.5; + public bool UseDevelopmentServer { get; } - protected OsuConfigManager LocalConfig; - - protected BeatmapManager BeatmapManager; - - protected ScoreManager ScoreManager; - - protected BeatmapDifficultyCache DifficultyCache; - - protected UserLookupCache UserCache; - - protected SkinManager SkinManager; - - protected RulesetStore RulesetStore; - - protected FileStore FileStore; - - protected KeyBindingStore KeyBindingStore; - - protected SettingsStore SettingsStore; - - protected RulesetConfigCache RulesetConfigCache; - - protected IAPIProvider API; - - private SpectatorStreamingClient spectatorStreaming; - private StatefulMultiplayerClient multiplayerClient; - - protected MenuCursorContainer MenuCursorContainer; - - protected MusicController MusicController; - - private Container content; - - protected override Container Content => content; - - protected Storage Storage { get; set; } - - [Cached] - [Cached(typeof(IBindable))] - protected readonly Bindable Ruleset = new Bindable(); - - /// - /// The current mod selection for the local user. - /// - /// - /// If a mod select overlay is present, mod instances set to this value are not guaranteed to remain as the provided instance and will be overwritten by a copy. - /// In such a case, changes to settings of a mod will *not* propagate after a mod is added to this collection. - /// As such, all settings should be finalised before adding a mod to this collection. - /// - [Cached] - [Cached(typeof(IBindable>))] - protected readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); - - /// - /// Mods available for the current . - /// - public readonly Bindable>> AvailableMods = new Bindable>>(); - - protected Bindable Beatmap { get; private set; } // cached via load() method - - private Bindable fpsDisplayVisible; - public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); /// @@ -141,28 +83,83 @@ namespace osu.Game } } + protected OsuConfigManager LocalConfig { get; private set; } + + protected SessionStatics SessionStatics { get; private set; } + + protected BeatmapManager BeatmapManager { get; private set; } + + protected ScoreManager ScoreManager { get; private set; } + + protected SkinManager SkinManager { get; private set; } + + protected RulesetStore RulesetStore { get; private set; } + + protected KeyBindingStore KeyBindingStore { get; private set; } + + protected MenuCursorContainer MenuCursorContainer { get; private set; } + + protected MusicController MusicController { get; private set; } + + protected IAPIProvider API { get; set; } + + protected Storage Storage { get; set; } + + protected Bindable Beatmap { get; private set; } // cached via load() method + + [Cached] + [Cached(typeof(IBindable))] + protected readonly Bindable Ruleset = new Bindable(); + + /// + /// The current mod selection for the local user. + /// + /// + /// If a mod select overlay is present, mod instances set to this value are not guaranteed to remain as the provided instance and will be overwritten by a copy. + /// In such a case, changes to settings of a mod will *not* propagate after a mod is added to this collection. + /// As such, all settings should be finalised before adding a mod to this collection. + /// + [Cached] + [Cached(typeof(IBindable>))] + protected readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); + + /// + /// Mods available for the current . + /// + public readonly Bindable>> AvailableMods = new Bindable>>(); + + private BeatmapDifficultyCache difficultyCache; + + private UserLookupCache userCache; + + private FileStore fileStore; + + private SettingsStore settingsStore; + + private RulesetConfigCache rulesetConfigCache; + + private SpectatorClient spectatorClient; + + private MultiplayerClient multiplayerClient; + + private DatabaseContextFactory contextFactory; + + protected override Container Content => content; + + private Container content; + + private DependencyContainer dependencies; + + private Bindable fpsDisplayVisible; + + private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(GLOBAL_TRACK_VOLUME_ADJUST); + public OsuGameBase() { UseDevelopmentServer = DebugUtils.IsDebugBuild; Name = @"osu!lazer"; } - private DependencyContainer dependencies; - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => - dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - - private DatabaseContextFactory contextFactory; - - protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager(); - - /// - /// The maximum volume at which audio tracks should playback. This can be set lower than 1 to create some head-room for sound effects. - /// - internal const double GLOBAL_TRACK_VOLUME_ADJUST = 0.5; - - private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(GLOBAL_TRACK_VOLUME_ADJUST); - [BackgroundDependencyLoader] private void load() { @@ -212,7 +209,7 @@ namespace osu.Game runMigrations(); - dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Audio, new NamespacedResourceStore(Resources, "Skins/Legacy"))); + dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Resources, Audio)); dependencies.CacheAs(SkinManager); // needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo. @@ -235,17 +232,17 @@ namespace osu.Game dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); - dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient(endpoints)); - dependencies.CacheAs(multiplayerClient = new MultiplayerClient(endpoints)); + dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints)); + dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints)); var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); dependencies.Cache(RulesetStore = new RulesetStore(contextFactory, Storage)); - dependencies.Cache(FileStore = new FileStore(contextFactory, Storage)); + dependencies.Cache(fileStore = new FileStore(contextFactory, Storage)); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() - dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Host, () => DifficultyCache, LocalConfig)); - dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Host, defaultBeatmap, true)); + dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Host, () => difficultyCache, LocalConfig)); + dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, true)); // this should likely be moved to ArchiveModelManager when another case appers where it is necessary // to have inter-dependent model managers. this could be obtained with an IHasForeign interface to @@ -268,20 +265,25 @@ namespace osu.Game ScoreManager.Undelete(getBeatmapScores(item), true); }); - dependencies.Cache(DifficultyCache = new BeatmapDifficultyCache()); - AddInternal(DifficultyCache); + dependencies.Cache(difficultyCache = new BeatmapDifficultyCache()); + AddInternal(difficultyCache); - dependencies.Cache(UserCache = new UserLookupCache()); - AddInternal(UserCache); + dependencies.Cache(userCache = new UserLookupCache()); + AddInternal(userCache); var scorePerformanceManager = new ScorePerformanceCache(); dependencies.Cache(scorePerformanceManager); AddInternal(scorePerformanceManager); dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore)); - dependencies.Cache(SettingsStore = new SettingsStore(contextFactory)); - dependencies.Cache(RulesetConfigCache = new RulesetConfigCache(SettingsStore)); - dependencies.Cache(new SessionStatics()); + dependencies.Cache(settingsStore = new SettingsStore(contextFactory)); + dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(settingsStore)); + + var powerStatus = CreateBatteryInfo(); + if (powerStatus != null) + dependencies.CacheAs(powerStatus); + + dependencies.Cache(SessionStatics = new SessionStatics()); dependencies.Cache(new OsuColour()); RegisterImportHandler(BeatmapManager); @@ -298,27 +300,28 @@ namespace osu.Game dependencies.CacheAs>(Beatmap); dependencies.CacheAs(Beatmap); - FileStore.Cleanup(); + fileStore.Cleanup(); // add api components to hierarchy. if (API is APIAccess apiAccess) AddInternal(apiAccess); - AddInternal(spectatorStreaming); + AddInternal(spectatorClient); AddInternal(multiplayerClient); - AddInternal(RulesetConfigCache); - - MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }; + AddInternal(rulesetConfigCache); GlobalActionContainer globalBindings; - MenuCursorContainer.Child = globalBindings = new GlobalActionContainer(this) + var mainContent = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both } + MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }, + // to avoid positional input being blocked by children, ensure the GlobalActionContainer is above everything. + globalBindings = new GlobalActionContainer(this) }; - base.Content.Add(CreateScalingContainer().WithChild(MenuCursorContainer)); + MenuCursorContainer.Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }; + + base.Content.Add(CreateScalingContainer().WithChildren(mainContent)); KeyBindingStore.Register(globalBindings); dependencies.Cache(globalBindings); @@ -333,24 +336,6 @@ namespace osu.Game Ruleset.BindValueChanged(onRulesetChanged); } - private void onRulesetChanged(ValueChangedEvent r) - { - var dict = new Dictionary>(); - - if (r.NewValue?.Available == true) - { - foreach (ModType type in Enum.GetValues(typeof(ModType))) - dict[type] = r.NewValue.CreateInstance().GetModsFor(type).ToList(); - } - - if (!SelectedMods.Disabled) - SelectedMods.Value = Array.Empty(); - - AvailableMods.Value = dict; - } - - protected virtual Container CreateScalingContainer() => new DrawSizePreservingFillContainer(); - protected override void LoadComplete() { base.LoadComplete(); @@ -364,28 +349,8 @@ namespace osu.Game FrameStatistics.ValueChanged += e => fpsDisplayVisible.Value = e.NewValue != FrameStatisticsMode.None; } - private void runMigrations() - { - try - { - using (var db = contextFactory.GetForWrite(false)) - db.Context.Migrate(); - } - catch (Exception e) - { - Logger.Error(e.InnerException ?? e, "Migration failed! We'll be starting with a fresh database.", LoggingTarget.Database); - - // if we failed, let's delete the database and start fresh. - // todo: we probably want a better (non-destructive) migrations/recovery process at a later point than this. - contextFactory.ResetDatabase(); - - Logger.Log("Database purged successfully.", LoggingTarget.Database); - - // only run once more, then hard bail. - using (var db = contextFactory.GetForWrite(false)) - db.Context.Migrate(); - } - } + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => + dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); public override void SetHost(GameHost host) { @@ -411,45 +376,59 @@ namespace osu.Game Scheduler.AddDelayed(GracefullyExit, 2000); } + public void Migrate(string path) + { + contextFactory.FlushConnections(); + (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); + } + + protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager(); + + protected virtual BatteryInfo CreateBatteryInfo() => null; + + protected virtual Container CreateScalingContainer() => new DrawSizePreservingFillContainer(); + protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); - private readonly List fileImporters = new List(); - - /// - /// Register a global handler for file imports. Most recently registered will have precedence. - /// - /// The handler to register. - public void RegisterImportHandler(ICanAcceptFiles handler) => fileImporters.Insert(0, handler); - - /// - /// Unregister a global handler for file imports. - /// - /// The previously registered handler. - public void UnregisterImportHandler(ICanAcceptFiles handler) => fileImporters.Remove(handler); - - public async Task Import(params string[] paths) + private void onRulesetChanged(ValueChangedEvent r) { - var extension = Path.GetExtension(paths.First())?.ToLowerInvariant(); + var dict = new Dictionary>(); - foreach (var importer in fileImporters) + if (r.NewValue?.Available == true) { - if (importer.HandledExtensions.Contains(extension)) - await importer.Import(paths); + foreach (ModType type in Enum.GetValues(typeof(ModType))) + dict[type] = r.NewValue.CreateInstance().GetModsFor(type).ToList(); + } + + if (!SelectedMods.Disabled) + SelectedMods.Value = Array.Empty(); + + AvailableMods.Value = dict; + } + + private void runMigrations() + { + try + { + using (var db = contextFactory.GetForWrite(false)) + db.Context.Migrate(); + } + catch (Exception e) + { + Logger.Error(e.InnerException ?? e, "Migration failed! We'll be starting with a fresh database.", LoggingTarget.Database); + + // if we failed, let's delete the database and start fresh. + // todo: we probably want a better (non-destructive) migrations/recovery process at a later point than this. + contextFactory.ResetDatabase(); + + Logger.Log("Database purged successfully.", LoggingTarget.Database); + + // only run once more, then hard bail. + using (var db = contextFactory.GetForWrite(false)) + db.Context.Migrate(); } } - public virtual async Task Import(params ImportTask[] tasks) - { - var tasksPerExtension = tasks.GroupBy(t => Path.GetExtension(t.Path).ToLowerInvariant()); - await Task.WhenAll(tasksPerExtension.Select(taskGroup => - { - var importer = fileImporters.FirstOrDefault(i => i.HandledExtensions.Contains(taskGroup.Key)); - return importer?.Import(taskGroup.ToArray()) ?? Task.CompletedTask; - })); - } - - public IEnumerable HandledExtensions => fileImporters.SelectMany(i => i.HandledExtensions); - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -460,37 +439,5 @@ namespace osu.Game contextFactory.FlushConnections(); } - - private class OsuUserInputManager : UserInputManager - { - protected override MouseButtonEventManager CreateButtonEventManagerFor(MouseButton button) - { - switch (button) - { - case MouseButton.Right: - return new RightMouseManager(button); - } - - return base.CreateButtonEventManagerFor(button); - } - - private class RightMouseManager : MouseButtonEventManager - { - public RightMouseManager(MouseButton button) - : base(button) - { - } - - public override bool EnableDrag => true; // allow right-mouse dragging for absolute scroll in scroll containers. - public override bool EnableClick => false; - public override bool ChangeFocusOnClick => false; - } - } - - public void Migrate(string path) - { - contextFactory.FlushConnections(); - (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); - } } } diff --git a/osu.Game/OsuGameBase_Importing.cs b/osu.Game/OsuGameBase_Importing.cs new file mode 100644 index 0000000000..d712da3553 --- /dev/null +++ b/osu.Game/OsuGameBase_Importing.cs @@ -0,0 +1,57 @@ +// 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 System.Threading.Tasks; +using osu.Game.Database; + +namespace osu.Game +{ + public partial class OsuGameBase + { + private readonly List fileImporters = new List(); + + /// + /// Register a global handler for file imports. Most recently registered will have precedence. + /// + /// The handler to register. + public void RegisterImportHandler(ICanAcceptFiles handler) => fileImporters.Insert(0, handler); + + /// + /// Unregister a global handler for file imports. + /// + /// The previously registered handler. + public void UnregisterImportHandler(ICanAcceptFiles handler) => fileImporters.Remove(handler); + + public async Task Import(params string[] paths) + { + if (paths.Length == 0) + return; + + var filesPerExtension = paths.GroupBy(p => Path.GetExtension(p).ToLowerInvariant()); + + foreach (var groups in filesPerExtension) + { + foreach (var importer in fileImporters) + { + if (importer.HandledExtensions.Contains(groups.Key)) + await importer.Import(groups.ToArray()).ConfigureAwait(false); + } + } + } + + public virtual async Task Import(params ImportTask[] tasks) + { + var tasksPerExtension = tasks.GroupBy(t => Path.GetExtension(t.Path).ToLowerInvariant()); + await Task.WhenAll(tasksPerExtension.Select(taskGroup => + { + var importer = fileImporters.FirstOrDefault(i => i.HandledExtensions.Contains(taskGroup.Key)); + return importer?.Import(taskGroup.ToArray()) ?? Task.CompletedTask; + })).ConfigureAwait(false); + } + + public IEnumerable HandledExtensions => fileImporters.SelectMany(i => i.HandledExtensions); + } +} diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs index b2096968fe..3d46e9ed94 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs @@ -23,14 +23,17 @@ namespace osu.Game.Overlays.AccountCreation private OsuTextFlowContainer multiAccountExplanationText; private LinkFlowContainer furtherAssistance; - [Resolved(CanBeNull = true)] + [Resolved(canBeNull: true)] private IAPIProvider api { get; set; } + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + private const string help_centre_url = "/help/wiki/Help_Centre#login"; public override void OnEntering(IScreen last) { - if (string.IsNullOrEmpty(api?.ProvidedUsername)) + if (string.IsNullOrEmpty(api?.ProvidedUsername) || game?.UseDevelopmentServer == true) { this.FadeOut(); this.Push(new ScreenEntry()); @@ -41,7 +44,7 @@ namespace osu.Game.Overlays.AccountCreation } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, OsuGame game, TextureStore textures) + private void load(OsuColour colours, TextureStore textures) { if (string.IsNullOrEmpty(api?.ProvidedUsername)) return; diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index bcc5a91677..1935a250b7 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -134,6 +134,7 @@ namespace osu.Game.Overlays.BeatmapListing queueUpdateSearch(true); }); + searchControl.General.CollectionChanged += (_, __) => queueUpdateSearch(); searchControl.Ruleset.BindValueChanged(_ => queueUpdateSearch()); searchControl.Category.BindValueChanged(_ => queueUpdateSearch()); searchControl.Genre.BindValueChanged(_ => queueUpdateSearch()); @@ -187,6 +188,7 @@ namespace osu.Game.Overlays.BeatmapListing searchControl.Query.Value, searchControl.Ruleset.Value, lastResponse?.Cursor, + searchControl.General, searchControl.Category.Value, sortControl.Current.Value, sortControl.SortDirection.Value, diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs index b138a5ac52..97ccb66599 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -29,6 +29,8 @@ namespace osu.Game.Overlays.BeatmapListing public Bindable Query => textBox.Current; + public BindableList General => generalFilter.Current; + public Bindable Ruleset => modeFilter.Current; public Bindable Category => categoryFilter.Current; @@ -61,6 +63,7 @@ namespace osu.Game.Overlays.BeatmapListing } private readonly BeatmapSearchTextBox textBox; + private readonly BeatmapSearchMultipleSelectionFilterRow generalFilter; private readonly BeatmapSearchRulesetFilterRow modeFilter; private readonly BeatmapSearchFilterRow categoryFilter; private readonly BeatmapSearchFilterRow genreFilter; @@ -87,7 +90,7 @@ namespace osu.Game.Overlays.BeatmapListing { RelativeSizeAxes = Axes.Both, Masking = true, - Child = beatmapCover = new UpdateableBeatmapSetCover + Child = beatmapCover = new TopSearchBeatmapSetCover { RelativeSizeAxes = Axes.Both, Alpha = 0, @@ -123,6 +126,7 @@ namespace osu.Game.Overlays.BeatmapListing Padding = new MarginPadding { Horizontal = 10 }, Children = new Drawable[] { + generalFilter = new BeatmapSearchMultipleSelectionFilterRow(@"General"), modeFilter = new BeatmapSearchRulesetFilterRow(), categoryFilter = new BeatmapSearchFilterRow(@"Categories"), genreFilter = new BeatmapSearchFilterRow(@"Genre"), @@ -180,5 +184,10 @@ namespace osu.Game.Overlays.BeatmapListing return true; } } + + private class TopSearchBeatmapSetCover : UpdateableBeatmapSetCover + { + protected override bool TransformImmediately => true; + } } } diff --git a/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs index c1d366bb82..4d5c387c4a 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs @@ -84,14 +84,14 @@ namespace osu.Game.Overlays.BeatmapListing.Panels { new OsuSpriteText { - Text = new LocalisedString((SetInfo.Metadata.TitleUnicode, SetInfo.Metadata.Title)), + Text = new RomanisableString(SetInfo.Metadata.TitleUnicode, SetInfo.Metadata.Title), Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true) }, } }, new OsuSpriteText { - Text = new LocalisedString((SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist)), + Text = new RomanisableString(SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist), Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true) }, }, diff --git a/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs index 76a30d1c11..00ffd168c1 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs @@ -107,14 +107,14 @@ namespace osu.Game.Overlays.BeatmapListing.Panels { new OsuSpriteText { - Text = new LocalisedString((SetInfo.Metadata.TitleUnicode, SetInfo.Metadata.Title)), + Text = new RomanisableString(SetInfo.Metadata.TitleUnicode, SetInfo.Metadata.Title), Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true) }, } }, new OsuSpriteText { - Text = new LocalisedString((SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist)), + Text = new RomanisableString(SetInfo.Metadata.ArtistUnicode, SetInfo.Metadata.Artist), Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true) }, } diff --git a/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs b/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs new file mode 100644 index 0000000000..175942c626 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; + +namespace osu.Game.Overlays.BeatmapListing +{ + public enum SearchGeneral + { + [Description("Recommended difficulty")] + Recommended, + + [Description("Include converted beatmaps")] + Converts, + + [Description("Subscribed mappers")] + Follows + } +} diff --git a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs index 31c1439c8f..1ffcf9722a 100644 --- a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs +++ b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs @@ -13,6 +13,8 @@ using osuTK.Graphics; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Users; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.BeatmapSet { @@ -50,7 +52,7 @@ namespace osu.Game.Overlays.BeatmapSet fields.Children = new Drawable[] { - new Field("mapped by", BeatmapSet.Metadata.Author.Username, OsuFont.GetFont(weight: FontWeight.Regular, italics: true)), + new Field("mapped by", BeatmapSet.Metadata.Author, OsuFont.GetFont(weight: FontWeight.Regular, italics: true)), new Field("submitted", online.Submitted, OsuFont.GetFont(weight: FontWeight.Bold)) { Margin = new MarginPadding { Top = 5 }, @@ -146,6 +148,25 @@ namespace osu.Game.Overlays.BeatmapSet } }; } + + public Field(string first, User second, FontUsage secondFont) + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Horizontal; + + Children = new[] + { + new LinkFlowContainer(s => + { + s.Font = OsuFont.GetFont(size: 11); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.AddText($"{first} "); + d.AddUserLink(second, s => s.Font = secondFont.With(size: 11)); + }), + }; + } } } } diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index a2464bef09..cf74c0d4d3 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -96,7 +97,7 @@ namespace osu.Game.Overlays.BeatmapSet public string TooltipText { get; } - public string Value + public LocalisableString Value { get => value.Text; set => this.value.Text = value; diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index 153aa41582..a61640a02e 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -228,8 +229,8 @@ namespace osu.Game.Overlays.BeatmapSet loading.Hide(); - title.Text = setInfo.NewValue.Metadata.Title ?? string.Empty; - artist.Text = setInfo.NewValue.Metadata.Artist ?? string.Empty; + title.Text = new RomanisableString(setInfo.NewValue.Metadata.TitleUnicode, setInfo.NewValue.Metadata.Title); + artist.Text = new RomanisableString(setInfo.NewValue.Metadata.ArtistUnicode, setInfo.NewValue.Metadata.Artist); explicitContentPill.Alpha = setInfo.NewValue.OnlineInfo.HasExplicitContent ? 1 : 0; diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs index cffff86a64..6d27342049 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs @@ -47,52 +47,44 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { FillFlowContainer textSprites; - AddRangeInternal(new Drawable[] + AddInternal(shakeContainer = new ShakeContainer { - shakeContainer = new ShakeContainer + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 5, + Child = button = new HeaderButton { RelativeSizeAxes = Axes.Both }, + }); + + button.AddRange(new Drawable[] + { + new Container { - Depth = -1, + Padding = new MarginPadding { Horizontal = 10 }, RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 5, Children = new Drawable[] { - button = new HeaderButton { RelativeSizeAxes = Axes.Both }, - new Container + textSprites = new FillFlowContainer { - // cannot nest inside here due to the structure of button (putting things in its own content). - // requires framework fix. - Padding = new MarginPadding { Horizontal = 10 }, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - textSprites = new FillFlowContainer - { - Depth = -1, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - AutoSizeDuration = 500, - AutoSizeEasing = Easing.OutQuint, - Direction = FillDirection.Vertical, - }, - new SpriteIcon - { - Depth = -1, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Icon = FontAwesome.Solid.Download, - Size = new Vector2(18), - }, - } + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 500, + AutoSizeEasing = Easing.OutQuint, + Direction = FillDirection.Vertical, }, - new DownloadProgressBar(BeatmapSet.Value) + new SpriteIcon { - Depth = -2, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Icon = FontAwesome.Solid.Download, + Size = new Vector2(18), }, - }, + } + }, + new DownloadProgressBar(BeatmapSet.Value) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, }, }); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index b598b7d97f..aff48919b4 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -60,7 +60,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores var scoreInfos = value.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToList(); var topScore = scoreInfos.First(); - scoreTable.DisplayScores(scoreInfos, topScore.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked); + scoreTable.DisplayScores(scoreInfos, topScore.Beatmap?.Status.GrantsPerformancePoints() == true); scoreTable.Show(); var userScore = value.UserScore; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 93744dd6a3..262f321598 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -112,7 +111,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores accuracyColumn.Text = value.DisplayAccuracy; maxComboColumn.Text = $@"{value.MaxCombo:N0}x"; - ppColumn.Alpha = value.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked ? 1 : 0; + + ppColumn.Alpha = value.Beatmap?.Status.GrantsPerformancePoints() == true ? 1 : 0; ppColumn.Text = $@"{value.PP:N0}"; statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn); @@ -204,7 +204,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores this.text = text; } - public LocalisedString Text + public string Text { set => text.Text = value; } diff --git a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs index 81315f9638..443b3dcf01 100644 --- a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs +++ b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs @@ -26,7 +26,10 @@ namespace osu.Game.Overlays AccentColour = colourProvider.Light2; } - protected override TabItem CreateTabItem(string value) => new ControlTabItem(value); + protected override TabItem CreateTabItem(string value) => new ControlTabItem(value) + { + AccentColour = AccentColour, + }; private class ControlTabItem : BreadcrumbTabItem { diff --git a/osu.Game/Overlays/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index 537dd00727..e7d68853ad 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -21,6 +21,8 @@ namespace osu.Game.Overlays { public class ChangelogOverlay : OnlineOverlay { + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + public readonly Bindable Current = new Bindable(); private Sample sampleBack; @@ -126,8 +128,11 @@ namespace osu.Game.Overlays private Task initialFetchTask; - private void performAfterFetch(Action action) => fetchListing()?.ContinueWith(_ => - Schedule(action), TaskContinuationOptions.OnlyOnRanToCompletion); + private void performAfterFetch(Action action) => Schedule(() => + { + fetchListing()?.ContinueWith(_ => + Schedule(action), TaskContinuationOptions.OnlyOnRanToCompletion); + }); private Task fetchListing() { @@ -160,10 +165,10 @@ namespace osu.Game.Overlays tcs.SetException(e); }; - await API.PerformAsync(req); + await API.PerformAsync(req).ConfigureAwait(false); - await tcs.Task; - }); + return tcs.Task; + }).Unwrap(); } private CancellationTokenSource loadContentCancellation; diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 86ce724390..5f9c00b36a 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -275,7 +275,8 @@ namespace osu.Game.Overlays.Chat { if (!UserScrolling) { - ScrollToEnd(); + if (Current < ScrollableExtent) + ScrollToEnd(); lastExtent = ScrollableExtent; } }); diff --git a/osu.Game/Overlays/Chat/Selection/ChannelSection.cs b/osu.Game/Overlays/Chat/Selection/ChannelSection.cs index eac48ca5cb..537ac975ac 100644 --- a/osu.Game/Overlays/Chat/Selection/ChannelSection.cs +++ b/osu.Game/Overlays/Chat/Selection/ChannelSection.cs @@ -4,19 +4,17 @@ using System; using System.Collections.Generic; using System.Linq; -using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; +using osuTK; namespace osu.Game.Overlays.Chat.Selection { public class ChannelSection : Container, IHasFilterableChildren { - private readonly OsuSpriteText header; - public readonly FillFlowContainer ChannelFlow; public IEnumerable FilterableChildren => ChannelFlow.Children; @@ -29,12 +27,6 @@ namespace osu.Game.Overlays.Chat.Selection public bool FilteringActive { get; set; } - public string Header - { - get => header.Text; - set => header.Text = value.ToUpperInvariant(); - } - public IEnumerable Channels { set => ChannelFlow.ChildrenEnumerable = value.Select(c => new ChannelListItem(c)); @@ -47,9 +39,10 @@ namespace osu.Game.Overlays.Chat.Selection Children = new Drawable[] { - header = new OsuSpriteText + new OsuSpriteText { Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold), + Text = "All Channels".ToUpperInvariant() }, ChannelFlow = new FillFlowContainer { diff --git a/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs b/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs index be9ecc6746..231d7ca63c 100644 --- a/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs +++ b/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs @@ -131,11 +131,7 @@ namespace osu.Game.Overlays.Chat.Selection { sectionsFlow.ChildrenEnumerable = new[] { - new ChannelSection - { - Header = "All Channels", - Channels = channels, - }, + new ChannelSection { Channels = channels, }, }; foreach (ChannelSection s in sectionsFlow.Children) diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 28f2287514..285041800a 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -24,6 +24,8 @@ using osu.Game.Overlays.Chat.Tabs; using osuTK.Input; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Online; namespace osu.Game.Overlays @@ -31,8 +33,8 @@ namespace osu.Game.Overlays public class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent { public string IconTexture => "Icons/Hexacons/messaging"; - public string Title => "chat"; - public string Description => "join the real-time discussion"; + public LocalisableString Title => ChatStrings.HeaderTitle; + public LocalisableString Description => ChatStrings.HeaderDescription; private const float textbox_height = 60; private const float channel_selection_min_height = 0.3f; diff --git a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs index 57bf2af4d2..2f7f16dd6f 100644 --- a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs +++ b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Comments.Buttons { public abstract class CommentRepliesButton : CompositeDrawable { - protected string Text + protected LocalisableString Text { get => text.Text; set => text.Text = value; diff --git a/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs index 56588ef0a8..8c40d79f7a 100644 --- a/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs +++ b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs @@ -32,7 +32,7 @@ namespace osu.Game.Overlays.Comments { new SpriteIcon { - Icon = FontAwesome.Solid.Trash, + Icon = FontAwesome.Regular.TrashAlt, Size = new Vector2(14), }, countText = new OsuSpriteText diff --git a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs index c89699f2ee..3051ca7dbe 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Dashboard private FillFlowContainer userFlow; [Resolved] - private SpectatorStreamingClient spectatorStreaming { get; set; } + private SpectatorClient spectatorClient { get; set; } [BackgroundDependencyLoader] private void load() @@ -52,7 +52,7 @@ namespace osu.Game.Overlays.Dashboard { base.LoadComplete(); - playingUsers.BindTo(spectatorStreaming.PlayingUsers); + playingUsers.BindTo(spectatorClient.PlayingUsers); playingUsers.BindCollectionChanged(onUsersChanged, true); } @@ -137,7 +137,7 @@ namespace osu.Game.Overlays.Dashboard Text = "Watch", Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Action = () => game?.PerformFromScreen(s => s.Push(new Spectator(User))), + Action = () => game?.PerformFromScreen(s => s.Push(new SoloSpectator(User))), Enabled = { Value = User.Id != api.LocalUser.Value.Id } } } diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index e6fe6ac749..0922ce5ecc 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -244,7 +244,7 @@ namespace osu.Game.Overlays.Dashboard.Friends return unsorted.OrderByDescending(u => u.LastVisit).ToList(); case UserSortCriteria.Rank: - return unsorted.OrderByDescending(u => u.CurrentModeRank.HasValue).ThenBy(u => u.CurrentModeRank ?? 0).ToList(); + return unsorted.OrderByDescending(u => u.Statistics.GlobalRank.HasValue).ThenBy(u => u.Statistics.GlobalRank ?? 0).ToList(); case UserSortCriteria.Username: return unsorted.OrderBy(u => u.Username).ToList(); diff --git a/osu.Game/Overlays/Dialog/ConfirmDialog.cs b/osu.Game/Overlays/Dialog/ConfirmDialog.cs new file mode 100644 index 0000000000..d1c0d746d1 --- /dev/null +++ b/osu.Game/Overlays/Dialog/ConfirmDialog.cs @@ -0,0 +1,42 @@ +// 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.Graphics.Sprites; + +namespace osu.Game.Overlays.Dialog +{ + /// + /// A dialog which confirms a user action. + /// + public class ConfirmDialog : PopupDialog + { + /// + /// Construct a new confirmation dialog. + /// + /// The description of the action to be displayed to the user. + /// An action to perform on confirmation. + /// An optional action to perform on cancel. + public ConfirmDialog(string message, Action onConfirm, Action onCancel = null) + { + HeaderText = message; + BodyText = "Last chance to turn back"; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Yes", + Action = onConfirm + }, + new PopupDialogCancelButton + { + Text = Localisation.CommonStrings.Cancel, + Action = onCancel + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 1bcbe4dd2f..cd02900e88 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -95,6 +95,10 @@ namespace osu.Game.Overlays.Dialog } } + // We always want dialogs to show their appear animation, so we request they start hidden. + // Normally this would not be required, but is here due to the manual Show() call that occurs before LoadComplete(). + protected override bool StartHidden => true; + protected PopupDialog() { RelativeSizeAxes = Axes.Both; @@ -205,8 +209,17 @@ namespace osu.Game.Overlays.Dialog }, }, }; + + // It's important we start in a visible state so our state fires on hide, even before load. + // This is used by the DialogOverlay to know when the dialog was dismissed. + Show(); } + /// + /// Programmatically clicks the first . + /// + public void PerformOkAction() => Buttons.OfType().First().Click(); + protected override bool OnKeyDown(KeyDownEvent e) { if (e.Repeat) return false; diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 4cc17a4c14..bc3b0e6c9a 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -35,15 +35,16 @@ namespace osu.Game.Overlays public void Push(PopupDialog dialog) { - if (dialog == CurrentDialog) return; + if (dialog == CurrentDialog || dialog.State.Value != Visibility.Visible) return; + // if any existing dialog is being displayed, dismiss it before showing a new one. CurrentDialog?.Hide(); + CurrentDialog = dialog; + CurrentDialog.State.ValueChanged += state => onDialogOnStateChanged(dialog, state.NewValue); dialogContainer.Add(CurrentDialog); - CurrentDialog.Show(); - CurrentDialog.State.ValueChanged += state => onDialogOnStateChanged(dialog, state.NewValue); Show(); } diff --git a/osu.Game/Overlays/FullscreenOverlay.cs b/osu.Game/Overlays/FullscreenOverlay.cs index 735f0bcbd4..58c41c4a4b 100644 --- a/osu.Game/Overlays/FullscreenOverlay.cs +++ b/osu.Game/Overlays/FullscreenOverlay.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osuTK.Graphics; @@ -18,8 +19,8 @@ namespace osu.Game.Overlays where T : OverlayHeader { public virtual string IconTexture => Header.Title.IconTexture ?? string.Empty; - public virtual string Title => Header.Title.Title ?? string.Empty; - public virtual string Description => Header.Title.Description ?? string.Empty; + public virtual LocalisableString Title => Header.Title.Title; + public virtual LocalisableString Description => Header.Title.Description; public T Header { get; } diff --git a/osu.Game/Overlays/INamedOverlayComponent.cs b/osu.Game/Overlays/INamedOverlayComponent.cs index 38fb8679a0..ca0aea041e 100644 --- a/osu.Game/Overlays/INamedOverlayComponent.cs +++ b/osu.Game/Overlays/INamedOverlayComponent.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.Localisation; + namespace osu.Game.Overlays { public interface INamedOverlayComponent { string IconTexture { get; } - string Title { get; } + LocalisableString Title { get; } - string Description { get; } + LocalisableString Description { get; } } } diff --git a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs index 9a27c55c53..c905397e77 100644 --- a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs @@ -21,6 +21,7 @@ namespace osu.Game.Overlays.KeyBinding { Add(new DefaultBindingsSubsection(manager)); Add(new AudioControlKeyBindingsSubsection(manager)); + Add(new SongSelectKeyBindingSubsection(manager)); Add(new InGameKeyBindingsSubsection(manager)); Add(new EditorKeyBindingsSubsection(manager)); } @@ -36,6 +37,17 @@ namespace osu.Game.Overlays.KeyBinding } } + private class SongSelectKeyBindingSubsection : KeyBindingsSubsection + { + protected override string Header => "Song Select"; + + public SongSelectKeyBindingSubsection(GlobalActionContainer manager) + : base(null) + { + Defaults = manager.SongSelectKeyBindings; + } + } + private class InGameKeyBindingsSubsection : KeyBindingsSubsection { protected override string Header => "In Game"; diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs index b808d49fa2..0df3359c28 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -16,6 +17,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Input; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -45,13 +47,20 @@ namespace osu.Game.Overlays.KeyBinding } } + private Container content; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + content.ReceivePositionalInputAt(screenSpacePos); + public bool FilteringActive { get; set; } private OsuSpriteText text; private FillFlowContainer cancelAndClearButtons; private FillFlowContainer buttons; - public IEnumerable FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend((string)text.Text); + private Bindable isDefault { get; } = new BindableBool(true); + + public IEnumerable FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend(text.Text.ToString()); public KeyBindingRow(object action, IEnumerable bindings) { @@ -60,9 +69,6 @@ namespace osu.Game.Overlays.KeyBinding RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - - Masking = true; - CornerRadius = padding; } [Resolved] @@ -71,51 +77,72 @@ namespace osu.Game.Overlays.KeyBinding [BackgroundDependencyLoader] private void load(OsuColour colours) { - EdgeEffect = new EdgeEffectParameters - { - Radius = 2, - Colour = colours.YellowDark.Opacity(0), - Type = EdgeEffectType.Shadow, - Hollow = true, - }; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }; - Children = new Drawable[] + InternalChildren = new Drawable[] { - new Box + new RestoreDefaultValueButton { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.6f, - }, - text = new OsuSpriteText - { - Text = action.GetDescription(), - Margin = new MarginPadding(padding), - }, - buttons = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight - }, - cancelAndClearButtons = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding(padding) { Top = height + padding * 2 }, - Anchor = Anchor.TopRight, + Current = isDefault, + Action = RestoreDefaults, Origin = Anchor.TopRight, - Alpha = 0, - Spacing = new Vector2(5), + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = padding, + EdgeEffect = new EdgeEffectParameters + { + Radius = 2, + Colour = colours.YellowDark.Opacity(0), + Type = EdgeEffectType.Shadow, + Hollow = true, + }, Children = new Drawable[] { - new CancelButton { Action = finalise }, - new ClearButton { Action = clear }, - }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.6f, + }, + text = new OsuSpriteText + { + Text = action.GetDescription(), + Margin = new MarginPadding(padding), + }, + buttons = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight + }, + cancelAndClearButtons = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(padding) { Top = height + padding * 2 }, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Alpha = 0, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new CancelButton { Action = finalise }, + new ClearButton { Action = clear }, + }, + } + } } }; foreach (var b in bindings) buttons.Add(new KeyButton(b)); + + updateIsDefaultValue(); } public void RestoreDefaults() @@ -128,18 +155,20 @@ namespace osu.Game.Overlays.KeyBinding button.UpdateKeyCombination(d); store.Update(button.KeyBinding); } + + isDefault.Value = true; } protected override bool OnHover(HoverEvent e) { - FadeEdgeEffectTo(1, transition_time, Easing.OutQuint); + content.FadeEdgeEffectTo(1, transition_time, Easing.OutQuint); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - FadeEdgeEffectTo(0, transition_time, Easing.OutQuint); + content.FadeEdgeEffectTo(0, transition_time, Easing.OutQuint); base.OnHoverLost(e); } @@ -287,6 +316,8 @@ namespace osu.Game.Overlays.KeyBinding { store.Update(bindTarget.KeyBinding); + updateIsDefaultValue(); + bindTarget.IsBinding = false; Schedule(() => { @@ -304,8 +335,8 @@ namespace osu.Game.Overlays.KeyBinding protected override void OnFocus(FocusEvent e) { - AutoSizeDuration = 500; - AutoSizeEasing = Easing.OutQuint; + content.AutoSizeDuration = 500; + content.AutoSizeEasing = Easing.OutQuint; cancelAndClearButtons.FadeIn(300, Easing.OutQuint); cancelAndClearButtons.BypassAutoSizeAxes &= ~Axes.Y; @@ -330,6 +361,11 @@ namespace osu.Game.Overlays.KeyBinding if (bindTarget != null) bindTarget.IsBinding = true; } + private void updateIsDefaultValue() + { + isDefault.Value = bindings.Select(b => b.KeyCombination).SequenceEqual(Defaults); + } + private class CancelButton : TriangleButton { public CancelButton() @@ -339,22 +375,13 @@ namespace osu.Game.Overlays.KeyBinding } } - public class ClearButton : TriangleButton + public class ClearButton : DangerousTriangleButton { public ClearButton() { Text = "Clear"; Size = new Vector2(80, 20); } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - BackgroundColour = colours.Pink; - - Triangles.ColourDark = colours.PinkDark; - Triangles.ColourLight = colours.PinkLight; - } } public class KeyButton : Container @@ -387,9 +414,6 @@ namespace osu.Game.Overlays.KeyBinding Margin = new MarginPadding(padding); - // todo: use this in a meaningful way - // var isDefault = keyBinding.Action is Enum; - Masking = true; CornerRadius = padding; @@ -454,6 +478,9 @@ namespace osu.Game.Overlays.KeyBinding public void UpdateKeyCombination(KeyCombination newCombination) { + if ((KeyBinding as DatabasedKeyBinding)?.RulesetID != null && !KeyBindingStore.CheckValidForGameplay(newCombination)) + return; + KeyBinding.KeyCombination = newCombination; Text.Text = KeyBinding.KeyCombination.ReadableString(); } diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs index d784b7aec9..5e1f9d8f75 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs @@ -11,7 +11,6 @@ using osu.Game.Input; using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osuTK; -using osu.Game.Graphics; namespace osu.Game.Overlays.KeyBinding { @@ -55,21 +54,20 @@ namespace osu.Game.Overlays.KeyBinding } } - public class ResetButton : TriangleButton + public class ResetButton : DangerousTriangleButton { [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { Text = "Reset all bindings in section"; RelativeSizeAxes = Axes.X; - Margin = new MarginPadding { Top = 5 }; - Height = 20; + Width = 0.5f; + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + Margin = new MarginPadding { Top = 15 }; + Height = 30; Content.CornerRadius = 5; - - BackgroundColour = colours.PinkDark; - Triangles.ColourDark = colours.PinkDarker; - Triangles.ColourLight = colours.Pink; } } } diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index c3e56abd05..aa8a5efd39 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -23,13 +23,15 @@ namespace osu.Game.Overlays.Mods public FillFlowContainer ButtonsContainer { get; } + protected IReadOnlyList Buttons { get; private set; } = Array.Empty(); + public Action Action; public Key[] ToggleKeys; public readonly ModType ModType; - public IEnumerable SelectedMods => buttons.Select(b => b.SelectedMod).Where(m => m != null); + public IEnumerable SelectedMods => Buttons.Select(b => b.SelectedMod).Where(m => m != null); private CancellationTokenSource modsLoadCts; @@ -77,7 +79,7 @@ namespace osu.Game.Overlays.Mods ButtonsContainer.ChildrenEnumerable = c; }, (modsLoadCts = new CancellationTokenSource()).Token); - buttons = modContainers.OfType().ToArray(); + Buttons = modContainers.OfType().ToArray(); header.FadeIn(200); this.FadeIn(200); @@ -88,8 +90,6 @@ namespace osu.Game.Overlays.Mods { } - private ModButton[] buttons = Array.Empty(); - protected override bool OnKeyDown(KeyDownEvent e) { if (e.ControlPressed) return false; @@ -97,8 +97,8 @@ namespace osu.Game.Overlays.Mods if (ToggleKeys != null) { var index = Array.IndexOf(ToggleKeys, e.Key); - if (index > -1 && index < buttons.Length) - buttons[index].SelectNext(e.ShiftPressed ? -1 : 1); + if (index > -1 && index < Buttons.Count) + Buttons[index].SelectNext(e.ShiftPressed ? -1 : 1); } return base.OnKeyDown(e); @@ -141,7 +141,7 @@ namespace osu.Game.Overlays.Mods { pendingSelectionOperations.Clear(); - foreach (var button in buttons.Where(b => !b.Selected)) + foreach (var button in Buttons.Where(b => !b.Selected)) pendingSelectionOperations.Enqueue(() => button.SelectAt(0)); } @@ -151,7 +151,7 @@ namespace osu.Game.Overlays.Mods public void DeselectAll() { pendingSelectionOperations.Clear(); - DeselectTypes(buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null)); + DeselectTypes(Buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null)); } /// @@ -161,7 +161,7 @@ namespace osu.Game.Overlays.Mods /// Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow. public void DeselectTypes(IEnumerable modTypes, bool immediate = false) { - foreach (var button in buttons) + foreach (var button in Buttons) { if (button.SelectedMod == null) continue; @@ -184,7 +184,7 @@ namespace osu.Game.Overlays.Mods /// The new list of selected mods to select. public void UpdateSelectedButtons(IReadOnlyList newSelectedMods) { - foreach (var button in buttons) + foreach (var button in Buttons) updateButtonSelection(button, newSelectedMods); } diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index eef91deb4c..e31e307d4d 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -96,7 +96,8 @@ namespace osu.Game.Overlays.Mods Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774"); Waves.FourthWaveColour = Color4Extensions.FromHex(@"003a4e"); - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; + Height = HEIGHT; Padding = new MarginPadding { Horizontal = -OsuScreen.HORIZONTAL_OVERFLOW_PADDING }; @@ -245,15 +246,21 @@ namespace osu.Game.Overlays.Mods }, } }, - ModSettingsContainer = new ModSettingsContainer + new Container { RelativeSizeAxes = Axes.Both, Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Width = 0.3f, - Alpha = 0, Padding = new MarginPadding(30), - SelectedMods = { BindTarget = SelectedMods }, + Width = 0.3f, + Children = new Drawable[] + { + ModSettingsContainer = new ModSettingsContainer + { + Alpha = 0, + SelectedMods = { BindTarget = SelectedMods }, + }, + } }, } }, @@ -456,6 +463,7 @@ namespace osu.Game.Overlays.Mods } updateSelectedButtons(); + OnAvailableModsChanged(); } /// @@ -533,6 +541,13 @@ namespace osu.Game.Overlays.Mods private void playSelectedSound() => sampleOn?.Play(); private void playDeselectedSound() => sampleOff?.Play(); + /// + /// Invoked after has changed. + /// + protected virtual void OnAvailableModsChanged() + { + } + /// /// Invoked when a new has been selected. /// diff --git a/osu.Game/Overlays/Music/PlaylistItem.cs b/osu.Game/Overlays/Music/PlaylistItem.cs index 96dff39fae..571b14428e 100644 --- a/osu.Game/Overlays/Music/PlaylistItem.cs +++ b/osu.Game/Overlays/Music/PlaylistItem.cs @@ -48,8 +48,8 @@ namespace osu.Game.Overlays.Music artistColour = colours.Gray9; HandleColour = colours.Gray5; - title = localisation.GetLocalisedString(new LocalisedString((Model.Metadata.TitleUnicode, Model.Metadata.Title))); - artist = localisation.GetLocalisedString(new LocalisedString((Model.Metadata.ArtistUnicode, Model.Metadata.Artist))); + title = localisation.GetLocalisedString(new RomanisableString(Model.Metadata.TitleUnicode, Model.Metadata.Title)); + artist = localisation.GetLocalisedString(new RomanisableString(Model.Metadata.ArtistUnicode, Model.Metadata.Artist)); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 3a9a6261ba..a15f80ca21 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -252,7 +252,7 @@ namespace osu.Game.Overlays if (playable != null) { - changeBeatmap(beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value)); + changeBeatmap(beatmaps.GetWorkingBeatmap(playable.Beatmaps.First())); restartTrack(); return PreviousTrackResult.Previous; } @@ -283,7 +283,7 @@ namespace osu.Game.Overlays if (playable != null) { - changeBeatmap(beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value)); + changeBeatmap(beatmaps.GetWorkingBeatmap(playable.Beatmaps.First())); restartTrack(); return true; } diff --git a/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs b/osu.Game/Overlays/News/Displays/ArticleListing.cs similarity index 60% rename from osu.Game/Overlays/News/Displays/FrontPageDisplay.cs rename to osu.Game/Overlays/News/Displays/ArticleListing.cs index 0f177f151a..dc3b17b323 100644 --- a/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs +++ b/osu.Game/Overlays/News/Displays/ArticleListing.cs @@ -1,34 +1,42 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osuTK; namespace osu.Game.Overlays.News.Displays { - public class FrontPageDisplay : CompositeDrawable + /// + /// Lists articles in a vertical flow for a specified year. + /// + public class ArticleListing : CompositeDrawable { - [Resolved] - private IAPIProvider api { get; set; } + private readonly Action fetchMorePosts; private FillFlowContainer content; private ShowMoreButton showMore; - private GetNewsRequest request; - private Cursor lastCursor; + private CancellationTokenSource cancellationToken; + + public ArticleListing(Action fetchMorePosts) + { + this.fetchMorePosts = fetchMorePosts; + } [BackgroundDependencyLoader] private void load() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + Padding = new MarginPadding { Vertical = 20, @@ -57,56 +65,25 @@ namespace osu.Game.Overlays.News.Displays { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Margin = new MarginPadding - { - Top = 15 - }, - Action = performFetch, + Margin = new MarginPadding { Top = 15 }, + Action = fetchMorePosts, Alpha = 0 } } }; - - performFetch(); } - private void performFetch() - { - request?.Cancel(); - - request = new GetNewsRequest(lastCursor); - request.Success += response => Schedule(() => onSuccess(response)); - api.PerformAsync(request); - } - - private CancellationTokenSource cancellationToken; - - private void onSuccess(GetNewsResponse response) - { - cancellationToken?.Cancel(); - - lastCursor = response.Cursor; - - var flow = new FillFlowContainer + public void AddPosts(IEnumerable posts, bool morePostsAvailable) => Schedule(() => + LoadComponentsAsync(posts.Select(p => new NewsCard(p)).ToList(), loaded => { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), - Children = response.NewsPosts.Select(p => new NewsCard(p)).ToList() - }; - - LoadComponentAsync(flow, loaded => - { - content.Add(loaded); + content.AddRange(loaded); showMore.IsLoading = false; - showMore.Show(); - }, (cancellationToken = new CancellationTokenSource()).Token); - } + showMore.Alpha = morePostsAvailable ? 1 : 0; + }, (cancellationToken = new CancellationTokenSource()).Token) + ); protected override void Dispose(bool isDisposing) { - request?.Cancel(); cancellationToken?.Cancel(); base.Dispose(isDisposing); } diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs index 63174128e7..94bfd62c32 100644 --- a/osu.Game/Overlays/News/NewsHeader.cs +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -19,13 +19,18 @@ namespace osu.Game.Overlays.News { TabControl.AddItem(front_page_string); + article.BindValueChanged(onArticleChanged, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(e => { if (e.NewValue == front_page_string) ShowFrontPage?.Invoke(); }); - - article.BindValueChanged(onArticleChanged, true); } public void SetFrontPage() => article.Value = null; diff --git a/osu.Game/Overlays/News/Sidebar/MonthSection.cs b/osu.Game/Overlays/News/Sidebar/MonthSection.cs new file mode 100644 index 0000000000..b300a755f9 --- /dev/null +++ b/osu.Game/Overlays/News/Sidebar/MonthSection.cs @@ -0,0 +1,179 @@ +// 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.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Graphics.Containers; +using osuTK; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; +using System.Linq; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using System.Diagnostics; +using osu.Framework.Platform; + +namespace osu.Game.Overlays.News.Sidebar +{ + public class MonthSection : CompositeDrawable + { + private const int animation_duration = 250; + + public readonly BindableBool Expanded = new BindableBool(); + + public MonthSection(int month, int year, IEnumerable posts) + { + Debug.Assert(posts.All(p => p.PublishedAt.Month == month && p.PublishedAt.Year == year)); + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DropdownHeader(month, year) + { + Expanded = { BindTarget = Expanded } + }, + new PostsContainer + { + Expanded = { BindTarget = Expanded }, + Children = posts.Select(p => new PostButton(p)).ToArray() + } + } + }; + } + + private class DropdownHeader : OsuClickableContainer + { + public readonly BindableBool Expanded = new BindableBool(); + + private readonly SpriteIcon icon; + + public DropdownHeader(int month, int year) + { + var date = new DateTime(year, month, 1); + + RelativeSizeAxes = Axes.X; + Height = 15; + Action = Expanded.Toggle; + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Text = date.ToString("MMM yyyy") + }, + icon = new SpriteIcon + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(10), + Icon = FontAwesome.Solid.ChevronDown + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Expanded.BindValueChanged(open => + { + icon.Scale = new Vector2(1, open.NewValue ? -1 : 1); + }, true); + } + } + + private class PostButton : OsuHoverContainer + { + protected override IEnumerable EffectTargets => new[] { text }; + + private readonly TextFlowContainer text; + private readonly APINewsPost post; + + public PostButton(APINewsPost post) + { + this.post = post; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Child = text = new TextFlowContainer(t => t.Font = OsuFont.GetFont(size: 12)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = post.Title + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider overlayColours, GameHost host) + { + IdleColour = overlayColours.Light2; + HoverColour = overlayColours.Light1; + + TooltipText = "view in browser"; + Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); + } + } + + private class PostsContainer : Container + { + public readonly BindableBool Expanded = new BindableBool(); + + protected override Container Content { get; } + + public PostsContainer() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + AutoSizeDuration = animation_duration; + AutoSizeEasing = Easing.Out; + InternalChild = Content = new FillFlowContainer + { + Margin = new MarginPadding { Top = 5 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Alpha = 0 + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Expanded.BindValueChanged(updateState, true); + } + + private void updateState(ValueChangedEvent expanded) + { + ClearTransforms(true); + + if (expanded.NewValue) + { + AutoSizeAxes = Axes.Y; + Content.FadeIn(animation_duration, Easing.OutQuint); + } + else + { + AutoSizeAxes = Axes.None; + this.ResizeHeightTo(0, animation_duration, Easing.OutQuint); + + Content.FadeOut(animation_duration, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs new file mode 100644 index 0000000000..9e397e78c8 --- /dev/null +++ b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs @@ -0,0 +1,129 @@ +// 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.Containers; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Graphics.Shapes; +using osuTK; +using System.Linq; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.News.Sidebar +{ + public class NewsSidebar : CompositeDrawable + { + [Cached] + public readonly Bindable Metadata = new Bindable(); + + private FillFlowContainer monthsFlow; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.Y; + Width = 250; + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + new Box + { + RelativeSizeAxes = Axes.Y, + Width = OsuScrollContainer.SCROLL_BAR_HEIGHT, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Colour = colourProvider.Background3, + Alpha = 0.5f + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 3 }, // Addeded 3px back + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Vertical = 20, + Left = 50, + Right = 30 + }, + Child = new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + new YearsPanel(), + monthsFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10) + } + } + } + } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Metadata.BindValueChanged(onMetadataChanged, true); + } + + private void onMetadataChanged(ValueChangedEvent metadata) + { + monthsFlow.Clear(); + + if (metadata.NewValue == null) + return; + + var allPosts = metadata.NewValue.NewsPosts; + + if (allPosts?.Any() != true) + return; + + var lookup = metadata.NewValue.NewsPosts.ToLookup(post => post.PublishedAt.Month); + + var keys = lookup.Select(kvp => kvp.Key); + var sortedKeys = keys.OrderByDescending(k => k).ToList(); + + var year = metadata.NewValue.CurrentYear; + + for (int i = 0; i < sortedKeys.Count; i++) + { + var month = sortedKeys[i]; + var posts = lookup[month]; + + monthsFlow.Add(new MonthSection(month, year, posts) + { + Expanded = { Value = i == 0 } + }); + } + } + } +} diff --git a/osu.Game/Overlays/News/Sidebar/YearsPanel.cs b/osu.Game/Overlays/News/Sidebar/YearsPanel.cs new file mode 100644 index 0000000000..b07c9924b9 --- /dev/null +++ b/osu.Game/Overlays/News/Sidebar/YearsPanel.cs @@ -0,0 +1,120 @@ +// 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.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.News.Sidebar +{ + public class YearsPanel : CompositeDrawable + { + private readonly Bindable metadata = new Bindable(); + + private FillFlowContainer yearsFlow; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider overlayColours, Bindable metadata) + { + this.metadata.BindTo(metadata); + + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + Masking = true; + CornerRadius = 6; + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = overlayColours.Background3 + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(5), + Child = yearsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 5) + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + metadata.BindValueChanged(_ => recreateDrawables(), true); + } + + private void recreateDrawables() + { + yearsFlow.Clear(); + + if (metadata.Value == null) + { + Hide(); + return; + } + + var currentYear = metadata.Value.CurrentYear; + + foreach (var y in metadata.Value.Years) + yearsFlow.Add(new YearButton(y, y == currentYear)); + + Show(); + } + + public class YearButton : OsuHoverContainer + { + public int Year { get; } + + [Resolved(canBeNull: true)] + private NewsOverlay overlay { get; set; } + + private readonly bool isCurrent; + + public YearButton(int year, bool isCurrent) + { + Year = year; + this.isCurrent = isCurrent; + + RelativeSizeAxes = Axes.X; + Width = 0.25f; + Height = 15; + + Child = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 12, weight: isCurrent ? FontWeight.SemiBold : FontWeight.Medium), + Text = year.ToString() + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = isCurrent ? Color4.White : colourProvider.Light2; + HoverColour = isCurrent ? Color4.White : colourProvider.Light1; + Action = () => + { + if (!isCurrent) + overlay?.ShowYear(Year); + }; + } + } + } +} diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs index 5beb285216..12e3f81ca1 100644 --- a/osu.Game/Overlays/NewsOverlay.cs +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -1,21 +1,72 @@ // 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 osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests; using osu.Game.Overlays.News; using osu.Game.Overlays.News.Displays; +using osu.Game.Overlays.News.Sidebar; namespace osu.Game.Overlays { public class NewsOverlay : OnlineOverlay { - private readonly Bindable article = new Bindable(null); + private readonly Bindable article = new Bindable(); + + private readonly Container sidebarContainer; + private readonly NewsSidebar sidebar; + private readonly Container content; + + private GetNewsRequest request; + + private Cursor lastCursor; + + /// + /// The year currently being displayed. If null, the main listing is being displayed. + /// + private int? displayedYear; + + private CancellationTokenSource cancellationToken; + + private bool displayUpdateRequired = true; public NewsOverlay() : base(OverlayColourScheme.Purple, false) { + Child = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + sidebarContainer = new Container + { + AutoSizeAxes = Axes.X, + Child = sidebar = new NewsSidebar() + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }; } protected override void LoadComplete() @@ -23,15 +74,16 @@ namespace osu.Game.Overlays base.LoadComplete(); // should not be run until first pop-in to avoid requesting data before user views. - article.BindValueChanged(onArticleChanged); + article.BindValueChanged(a => + { + if (a.NewValue == null) + loadListing(); + else + loadArticle(a.NewValue); + }); } - protected override NewsHeader CreateHeader() => new NewsHeader - { - ShowFrontPage = ShowFrontPage - }; - - private bool displayUpdateRequired = true; + protected override NewsHeader CreateHeader() => new NewsHeader { ShowFrontPage = ShowFrontPage }; protected override void PopIn() { @@ -56,42 +108,94 @@ namespace osu.Game.Overlays Show(); } + public void ShowYear(int year) + { + loadListing(year); + Show(); + } + public void ShowArticle(string slug) { article.Value = slug; Show(); } - private CancellationTokenSource cancellationToken; - - private void onArticleChanged(ValueChangedEvent e) - { - cancellationToken?.Cancel(); - Loading.Show(); - - if (e.NewValue == null) - { - Header.SetFrontPage(); - LoadDisplay(new FrontPageDisplay()); - return; - } - - Header.SetArticle(e.NewValue); - LoadDisplay(Empty()); - } - protected void LoadDisplay(Drawable display) { ScrollFlow.ScrollToStart(); LoadComponentAsync(display, loaded => { - Child = loaded; + content.Child = loaded; Loading.Hide(); }, (cancellationToken = new CancellationTokenSource()).Token); } + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + sidebarContainer.Height = DrawHeight; + sidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); + } + + private void loadListing(int? year = null) + { + Header.SetFrontPage(); + + displayedYear = year; + lastCursor = null; + + beginLoading(true); + + request = new GetNewsRequest(displayedYear); + request.Success += response => Schedule(() => + { + lastCursor = response.Cursor; + sidebar.Metadata.Value = response.SidebarMetadata; + + var listing = new ArticleListing(getMorePosts); + listing.AddPosts(response.NewsPosts, response.Cursor != null); + LoadDisplay(listing); + }); + + API.PerformAsync(request); + } + + private void getMorePosts() + { + beginLoading(false); + + request = new GetNewsRequest(displayedYear, lastCursor); + request.Success += response => Schedule(() => + { + lastCursor = response.Cursor; + if (content.Child is ArticleListing listing) + listing.AddPosts(response.NewsPosts, response.Cursor != null); + }); + + API.PerformAsync(request); + } + + private void loadArticle(string article) + { + // This is not yet implemented nor called from anywhere. + beginLoading(true); + + Header.SetArticle(article); + LoadDisplay(Empty()); + } + + private void beginLoading(bool showLoadingOverlay) + { + request?.Cancel(); + cancellationToken?.Cancel(); + + if (showLoadingOverlay) + Loading.Show(); + } + protected override void Dispose(bool isDisposing) { + request?.Cancel(); cancellationToken?.Cancel(); base.Dispose(isDisposing); } diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index d51d964fc4..b26e17b34c 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -11,16 +11,18 @@ using osu.Game.Graphics.Containers; using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Game.Graphics; +using osu.Game.Localisation; namespace osu.Game.Overlays { public class NotificationOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent { public string IconTexture => "Icons/Hexacons/notification"; - public string Title => "notifications"; - public string Description => "waiting for 'ya"; + public LocalisableString Title => NotificationsStrings.HeaderTitle; + public LocalisableString Description => NotificationsStrings.HeaderDescription; private const float width = 320; diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index 38ba712254..2316199049 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.cs @@ -8,10 +8,11 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Notifications { @@ -37,7 +38,7 @@ namespace osu.Game.Overlays.Notifications public NotificationSection(string title, string clearButtonText) { - this.clearButtonText = clearButtonText; + this.clearButtonText = clearButtonText.ToUpperInvariant(); titleText = title; } @@ -121,7 +122,20 @@ namespace osu.Game.Overlays.Notifications { base.Update(); - countDrawable.Text = notifications.Children.Count(c => c.Alpha > 0.99f).ToString(); + countDrawable.Text = getVisibleCount().ToString(); + } + + private int getVisibleCount() + { + int count = 0; + + foreach (var c in notifications) + { + if (c.Alpha > 0.99f) + count++; + } + + return count; } private class ClearAllButton : OsuClickableContainer @@ -138,10 +152,10 @@ namespace osu.Game.Overlays.Notifications }; } - public string Text + public LocalisableString Text { get => text.Text; - set => text.Text = value.ToUpperInvariant(); + set => text.Text = value; } } diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 2866d2ad6d..f88be91c01 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -19,6 +19,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays.Music; using osuTK; using osuTK.Graphics; @@ -28,8 +29,8 @@ namespace osu.Game.Overlays public class NowPlayingOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent { public string IconTexture => "Icons/Hexacons/music"; - public string Title => "now playing"; - public string Description => "manage the currently playing track"; + public LocalisableString Title => NowPlayingStrings.HeaderTitle; + public LocalisableString Description => NowPlayingStrings.HeaderDescription; private const float player_height = 130; private const float transition_length = 800; @@ -293,8 +294,8 @@ namespace osu.Game.Overlays else { BeatmapMetadata metadata = beatmap.Metadata; - title.Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)); - artist.Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)); + title.Text = new RomanisableString(metadata.TitleUnicode, metadata.Title); + artist.Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); } }); diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs index b2212336ef..0ebabd424f 100644 --- a/osu.Game/Overlays/OverlaySortTabControl.cs +++ b/osu.Game/Overlays/OverlaySortTabControl.cs @@ -17,6 +17,7 @@ using osu.Game.Overlays.Comments; using JetBrains.Annotations; using System; using osu.Framework.Extensions; +using osu.Framework.Localisation; namespace osu.Game.Overlays { @@ -30,7 +31,7 @@ namespace osu.Game.Overlays set => current.Current = value; } - public string Title + public LocalisableString Title { get => text.Text; set => text.Text = value; diff --git a/osu.Game/Overlays/OverlayTitle.cs b/osu.Game/Overlays/OverlayTitle.cs index c3ea35adfc..d92979e8d4 100644 --- a/osu.Game/Overlays/OverlayTitle.cs +++ b/osu.Game/Overlays/OverlayTitle.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -19,15 +20,15 @@ namespace osu.Game.Overlays private readonly OsuSpriteText titleText; private readonly Container icon; - private string title; + private LocalisableString title; - public string Title + public LocalisableString Title { get => title; protected set => titleText.Text = title = value; } - public string Description { get; protected set; } + public LocalisableString Description { get; protected set; } private string iconTexture; diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index 2925107766..fe61e532e1 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -113,7 +114,12 @@ namespace osu.Game.Overlays.Profile.Header } topLinkContainer.AddText("Contributed "); - topLinkContainer.AddLink($@"{user.PostCount:#,##0} forum posts", $"{api.WebsiteRootUrl}/users/{user.Id}/posts", creationParameters: embolden); + topLinkContainer.AddLink("forum post".ToQuantity(user.PostCount, "#,##0"), $"{api.WebsiteRootUrl}/users/{user.Id}/posts", creationParameters: embolden); + + addSpacer(topLinkContainer); + + topLinkContainer.AddText("Posted "); + topLinkContainer.AddLink("comment".ToQuantity(user.CommentsCount, "#,##0"), $"{api.WebsiteRootUrl}/comments?user_id={user.Id}", creationParameters: embolden); string websiteWithoutProtocol = user.Website; @@ -138,7 +144,6 @@ namespace osu.Game.Overlays.Profile.Header if (!string.IsNullOrEmpty(user.Twitter)) anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Twitter, "@" + user.Twitter, $@"https://twitter.com/{user.Twitter}"); anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Discord, user.Discord); - anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Skype, user.Skype, @"skype:" + user.Skype + @"?chat"); anyInfoAdded |= tryAddInfo(FontAwesome.Solid.Link, websiteWithoutProtocol, user.Website); // If no information was added to the bottomLinkContainer, hide it to avoid unwanted padding diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index 780d7ea986..fe9c710bcc 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -20,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps private readonly BeatmapSetType type; public PaginatedBeatmapContainer(BeatmapSetType type, Bindable user, string headerText) - : base(user, headerText, "", CounterVisibilityState.AlwaysVisible) + : base(user, headerText) { this.type = type; ItemsPerPage = 6; diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs index b82773155d..a48036dcbb 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs @@ -15,6 +15,11 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { private ProfileLineChart chart; + /// + /// Text describing the value being plotted on the graph, which will be displayed as a prefix to the value in the history graph tooltip. + /// + protected abstract string GraphCounterName { get; } + protected ChartProfileSubsection(Bindable user, string headerText) : base(user, headerText) { @@ -30,7 +35,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical Left = 20, Right = 40 }, - Child = chart = new ProfileLineChart() + Child = chart = new ProfileLineChart(GraphCounterName) }; protected override void LoadComplete() diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 5b7c5efbe2..6d6ff32aac 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; @@ -13,6 +12,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections.Historical { @@ -41,12 +41,11 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { AddRangeInternal(new Drawable[] { - new UpdateableBeatmapSetCover + new UpdateableBeatmapSetCover(BeatmapSetCoverType.List) { RelativeSizeAxes = Axes.Y, Width = cover_width, BeatmapSet = beatmap.BeatmapSet, - CoverType = BeatmapSetCoverType.List, }, new Container { @@ -129,14 +128,14 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { new OsuSpriteText { - Text = new LocalisedString(( + Text = new RomanisableString( $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} [{beatmap.Version}] ", - $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} [{beatmap.Version}] ")), + $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} [{beatmap.Version}] "), Font = OsuFont.GetFont(weight: FontWeight.Bold) }, new OsuSpriteText { - Text = "by " + new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), + Text = "by " + new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist), Font = OsuFont.GetFont(weight: FontWeight.Regular) }, }; diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index e5bb1f8008..eeb14e5e4f 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical public class PaginatedMostPlayedBeatmapContainer : PaginatedProfileSubsection { public PaginatedMostPlayedBeatmapContainer(Bindable user) - : base(user, "Most Played Beatmaps", "No records. :(", CounterVisibilityState.AlwaysVisible) + : base(user, "Most Played Beatmaps") { ItemsPerPage = 5; } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs index 2f15886c3a..dfd29db693 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs @@ -9,6 +9,8 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { public class PlayHistorySubsection : ChartProfileSubsection { + protected override string GraphCounterName => "Plays"; + public PlayHistorySubsection(Bindable user) : base(user, "Play History") { diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs index f02aa36b6c..eb5deb2802 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private readonly Container rowLinesContainer; private readonly Container columnLinesContainer; - public ProfileLineChart() + public ProfileLineChart(string graphCounterName) { RelativeSizeAxes = Axes.X; Height = 250; @@ -88,7 +88,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical } } }, - graph = new UserHistoryGraph + graph = new UserHistoryGraph(graphCounterName) { RelativeSizeAxes = Axes.Both } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs index e594e8d020..1c28306f17 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs @@ -9,6 +9,8 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { public class ReplaysSubsection : ChartProfileSubsection { + protected override string GraphCounterName => "Replays Watched"; + public ReplaysSubsection(Bindable user) : base(user, "Replays Watched History") { diff --git a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs index b1e8c8f0ca..52831b4243 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs @@ -11,25 +11,28 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { public class UserHistoryGraph : UserGraph { + private readonly string tooltipCounterName; + [CanBeNull] public UserHistoryCount[] Values { set => Data = value?.Select(v => new KeyValuePair(v.Date, v.Count)).ToArray(); } - /// - /// Text describing the value being plotted on the graph, which will be displayed as a prefix to the value in the . - /// - public string TooltipCounterName { get; set; } = "Plays"; + public UserHistoryGraph(string tooltipCounterName) + { + this.tooltipCounterName = tooltipCounterName; + } protected override float GetDataPointHeight(long playCount) => playCount; - protected override UserGraphTooltip GetTooltip() => new HistoryGraphTooltip(TooltipCounterName); + protected override UserGraphTooltip GetTooltip() => new HistoryGraphTooltip(tooltipCounterName); protected override object GetTooltipContent(DateTime date, long playCount) { return new TooltipDisplayContent { + Name = tooltipCounterName, Count = playCount.ToString("N0"), Date = date.ToString("MMMM yyyy") }; @@ -37,14 +40,17 @@ namespace osu.Game.Overlays.Profile.Sections.Historical protected class HistoryGraphTooltip : UserGraphTooltip { + private readonly string tooltipCounterName; + public HistoryGraphTooltip(string tooltipCounterName) : base(tooltipCounterName) { + this.tooltipCounterName = tooltipCounterName; } public override bool SetContent(object content) { - if (!(content is TooltipDisplayContent info)) + if (!(content is TooltipDisplayContent info) || info.Name != tooltipCounterName) return false; Counter.Text = info.Count; @@ -55,6 +61,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private class TooltipDisplayContent { + public string Name; public string Count; public string Date; } diff --git a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs index 6e2b9873cf..4fbb7fc7d7 100644 --- a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs +++ b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs @@ -20,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Sections { new PlayHistorySubsection(User), new PaginatedMostPlayedBeatmapContainer(User), - new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)", CounterVisibilityState.VisibleWhenZero), + new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)"), new ReplaysSubsection(User) }; } diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs index d4d0976724..cdb24b784c 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs @@ -23,51 +23,24 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu { this.user.BindTo(user); CountSection total; - CountSection avaliable; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Masking = true; CornerRadius = 3; - Children = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), - Children = new[] - { - total = new CountTotal(), - avaliable = new CountAvailable() - } - } - }; - this.user.ValueChanged += u => - { - total.Count = u.NewValue?.Kudosu.Total ?? 0; - avaliable.Count = u.NewValue?.Kudosu.Available ?? 0; - }; + Child = total = new CountTotal(); + + this.user.ValueChanged += u => total.Count = u.NewValue?.Kudosu.Total ?? 0; } protected override bool OnClick(ClickEvent e) => true; - private class CountAvailable : CountSection - { - public CountAvailable() - : base("Kudosu Avaliable") - { - DescriptionText.Text = "Kudosu can be traded for kudosu stars, which will help your beatmap get more attention. This is the number of kudosu you haven't traded in yet."; - } - } - private class CountTotal : CountSection { public CountTotal() : base("Total Kudosu Earned") { DescriptionText.AddText("Based on how much of a contribution the user has made to beatmap moderation. See "); - DescriptionText.AddLink("this link", "https://osu.ppy.sh/wiki/Kudosu"); + DescriptionText.AddLink("this page", "https://osu.ppy.sh/wiki/Kudosu"); DescriptionText.AddText(" for more information."); } } @@ -80,13 +53,12 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu public new int Count { - set => valueText.Text = value.ToString(); + set => valueText.Text = value.ToString("N0"); } public CountSection(string header) { RelativeSizeAxes = Axes.X; - Width = 0.5f; AutoSizeAxes = Axes.Y; Padding = new MarginPadding { Top = 10, Bottom = 20 }; Child = new FillFlowContainer @@ -131,7 +103,6 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu private void load(OverlayColourProvider colourProvider) { lineBackground.Colour = colourProvider.Highlight1; - DescriptionText.Colour = colourProvider.Foreground1; } } } diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index 51e5622f68..e237b43b2e 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -38,8 +38,8 @@ namespace osu.Game.Overlays.Profile.Sections private OsuSpriteText missing; private readonly string missingText; - protected PaginatedProfileSubsection(Bindable user, string headerText = "", string missingText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) - : base(user, headerText, counterVisibilityState) + protected PaginatedProfileSubsection(Bindable user, string headerText = "", string missingText = "") + : base(user, headerText, CounterVisibilityState.AlwaysVisible) { this.missingText = missingText; } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 2c20dcc0ef..713303285a 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -256,16 +256,16 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = new LocalisedString(( + Text = new RomanisableString( $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} ", - $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} ")), + $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} "), Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold, italics: true) }, new OsuSpriteText { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = "by " + new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), + Text = "by " + new RomanisableString(beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist), Font = OsuFont.GetFont(size: 12, italics: true) }, }; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index 53f6d375ca..720cd4a3db 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -18,8 +18,8 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { private readonly ScoreType type; - public PaginatedScoreContainer(ScoreType type, Bindable user, string headerText, CounterVisibilityState counterVisibilityState, string missingText = "") - : base(user, headerText, missingText, counterVisibilityState) + public PaginatedScoreContainer(ScoreType type, Bindable user, string headerText) + : base(user, headerText) { this.type = type; @@ -36,9 +36,15 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { switch (type) { + case ScoreType.Best: + return user.ScoresBestCount; + case ScoreType.Firsts: return user.ScoresFirstCount; + case ScoreType.Recent: + return user.ScoresRecentCount; + default: return 0; } @@ -50,9 +56,6 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks drawableItemIndex = 0; base.OnItemsReceived(items); - - if (type == ScoreType.Recent) - SetCount(items.Count); } protected override APIRequest> CreateRequest() => diff --git a/osu.Game/Overlays/Profile/Sections/RanksSection.cs b/osu.Game/Overlays/Profile/Sections/RanksSection.cs index e41e414893..33f7c2f71a 100644 --- a/osu.Game/Overlays/Profile/Sections/RanksSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RanksSection.cs @@ -16,8 +16,8 @@ namespace osu.Game.Overlays.Profile.Sections { Children = new[] { - new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance", CounterVisibilityState.AlwaysHidden, "No performance records. :("), - new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks", CounterVisibilityState.AlwaysVisible) + new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance"), + new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks") }; } } diff --git a/osu.Game/Overlays/RestoreDefaultValueButton.cs b/osu.Game/Overlays/RestoreDefaultValueButton.cs new file mode 100644 index 0000000000..213ad2ba68 --- /dev/null +++ b/osu.Game/Overlays/RestoreDefaultValueButton.cs @@ -0,0 +1,106 @@ +// 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 osuTK.Graphics; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays +{ + public class RestoreDefaultValueButton : OsuButton, IHasTooltip, IHasCurrentValue + { + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + // this is done to ensure a click on this button doesn't trigger focus on a parent element which contains the button. + public override bool AcceptsFocus => true; + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private Color4 buttonColour; + + private bool hovering; + + public RestoreDefaultValueButton() + { + Height = 1; + + RelativeSizeAxes = Axes.Y; + Width = SettingsPanel.CONTENT_MARGINS; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + BackgroundColour = colour.Yellow; + buttonColour = colour.Yellow; + Content.Width = 0.33f; + Content.CornerRadius = 3; + Content.EdgeEffect = new EdgeEffectParameters + { + Colour = buttonColour.Opacity(0.1f), + Type = EdgeEffectType.Glow, + Radius = 2, + }; + + Padding = new MarginPadding { Vertical = 1.5f }; + Alpha = 0f; + + Action += () => + { + if (!current.Disabled) current.SetDefault(); + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.ValueChanged += _ => UpdateState(); + Current.DisabledChanged += _ => UpdateState(); + Current.DefaultChanged += _ => UpdateState(); + + UpdateState(); + } + + public string TooltipText => "revert to default"; + + protected override bool OnHover(HoverEvent e) + { + hovering = true; + UpdateState(); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hovering = false; + UpdateState(); + } + + public void UpdateState() => Scheduler.AddOnce(updateState); + + private void updateState() + { + if (current == null) + return; + + this.FadeTo(current.IsDefault ? 0f : + hovering && !current.Disabled ? 1f : 0.65f, 200, Easing.OutQuint); + this.FadeColour(current.Disabled ? Color4.Gray : buttonColour, 200, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index bed74542c9..b31e7dc45b 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -6,6 +6,7 @@ using osu.Framework.Audio; using osu.Framework.Graphics; using System.Collections.Generic; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings.Sections.Audio @@ -76,7 +77,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio private class AudioDeviceDropdownControl : DropdownControl { - protected override string GenerateItemText(string item) + protected override LocalisableString GenerateItemText(string item) => string.IsNullOrEmpty(item) ? "Default" : base.GenerateItemText(item); } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index be464fa2b7..0b5ec4f338 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -73,11 +73,6 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay LabelText = "Always play first combo break sound", Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak) }, - new SettingsEnumDropdown - { - LabelText = "Score meter type", - Current = config.GetBindable(OsuSetting.ScoreMeter) - }, new SettingsEnumDropdown { LabelText = "Score display mode", diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index 44e42ecbfe..c2767f61b4 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -1,27 +1,45 @@ // 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.Configuration; using osu.Framework.Graphics; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.General { public class LanguageSettings : SettingsSubsection { + private SettingsDropdown languageSelection; + private Bindable frameworkLocale; + protected override string Header => "Language"; [BackgroundDependencyLoader] private void load(FrameworkConfigManager frameworkConfig) { + frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); + Children = new Drawable[] { + languageSelection = new SettingsEnumDropdown + { + LabelText = "Language", + }, new SettingsCheckbox { LabelText = "Prefer metadata in original language", Current = frameworkConfig.GetBindable(FrameworkSetting.ShowUnicode) }, }; + + if (!Enum.TryParse(frameworkLocale.Value, out var locale)) + locale = Language.en; + languageSelection.Current.Value = locale; + + languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToString()); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 7acbf038d8..937bcc8abf 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -11,6 +11,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Graphics.Containers; @@ -140,7 +141,14 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingSettings.ForEach(s => bindPreviewEvent(s.Current)); - windowModeDropdown.Current.ValueChanged += _ => updateResolutionDropdown(); + windowModeDropdown.Current.BindValueChanged(mode => + { + updateResolutionDropdown(); + + const string not_fullscreen_note = "Running without fullscreen mode will increase your input latency!"; + + windowModeDropdown.WarningText = mode.NewValue != WindowMode.Fullscreen ? not_fullscreen_note : string.Empty; + }, true); windowModes.BindCollectionChanged((sender, args) => { @@ -234,7 +242,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private class ResolutionDropdownControl : DropdownControl { - protected override string GenerateItemText(Size item) + protected override LocalisableString GenerateItemText(Size item) { if (item == new Size(9999, 9999)) return "Default"; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 8773e6763c..70225ff6b8 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -13,6 +13,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { protected override string Header => "Renderer"; + private SettingsEnumDropdown frameLimiterDropdown; + [BackgroundDependencyLoader] private void load(FrameworkConfigManager config, OsuConfigManager osuConfig) { @@ -20,7 +22,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics Children = new Drawable[] { // TODO: this needs to be a custom dropdown at some point - new SettingsEnumDropdown + frameLimiterDropdown = new SettingsEnumDropdown { LabelText = "Frame limiter", Current = config.GetBindable(FrameworkSetting.FrameSync) @@ -37,5 +39,17 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics }, }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + frameLimiterDropdown.Current.BindValueChanged(limit => + { + const string unlimited_frames_note = "Using unlimited frame limiter can lead to stutters, bad performance and overheating. It will not improve perceived latency. \"2x refresh rate\" is recommended."; + + frameLimiterDropdown.WarningText = limit.NewValue == FrameSync.Unlimited ? unlimited_frames_note : string.Empty; + }, true); + } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs similarity index 70% rename from osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs rename to osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs index db6f24a954..79c73863cf 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs @@ -5,17 +5,17 @@ using osu.Framework.Graphics; namespace osu.Game.Overlays.Settings.Sections.Input { - public class KeyboardSettings : SettingsSubsection + public class BindingSettings : SettingsSubsection { - protected override string Header => "Keyboard"; + protected override string Header => "Shortcut and gameplay bindings"; - public KeyboardSettings(KeyBindingPanel keyConfig) + public BindingSettings(KeyBindingPanel keyConfig) { Children = new Drawable[] { new SettingsButton { - Text = "Key configuration", + Text = "Configure", TooltipText = "change global shortcut keys and gameplay bindings", Action = keyConfig.ToggleVisibility }, diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 455e13711d..fb908a7669 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -1,11 +1,11 @@ // 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; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Input.Handlers.Mouse; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Input; @@ -14,41 +14,44 @@ namespace osu.Game.Overlays.Settings.Sections.Input { public class MouseSettings : SettingsSubsection { + private readonly MouseHandler mouseHandler; + protected override string Header => "Mouse"; - private readonly BindableBool rawInputToggle = new BindableBool(); - private Bindable sensitivityBindable = new BindableDouble(); - private Bindable ignoredInputHandlers; + private Bindable handlerSensitivity; + + private Bindable localSensitivity; private Bindable windowMode; private SettingsEnumDropdown confineMouseModeSetting; + private Bindable relativeMode; + + public MouseSettings(MouseHandler mouseHandler) + { + this.mouseHandler = mouseHandler; + } [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig, FrameworkConfigManager config) { - var configSensitivity = config.GetBindable(FrameworkSetting.CursorSensitivity); - // use local bindable to avoid changing enabled state of game host's bindable. - sensitivityBindable = configSensitivity.GetUnboundCopy(); - configSensitivity.BindValueChanged(val => sensitivityBindable.Value = val.NewValue); - sensitivityBindable.BindValueChanged(val => configSensitivity.Value = val.NewValue); + handlerSensitivity = mouseHandler.Sensitivity.GetBoundCopy(); + localSensitivity = handlerSensitivity.GetUnboundCopy(); + + relativeMode = mouseHandler.UseRelativeMode.GetBoundCopy(); + windowMode = config.GetBindable(FrameworkSetting.WindowMode); Children = new Drawable[] { new SettingsCheckbox { - LabelText = "Raw input", - Current = rawInputToggle + LabelText = "High precision mouse", + Current = relativeMode }, new SensitivitySetting { LabelText = "Cursor sensitivity", - Current = sensitivityBindable - }, - new SettingsCheckbox - { - LabelText = "Map absolute input to window", - Current = config.GetBindable(FrameworkSetting.MapAbsoluteInputToWindow) + Current = localSensitivity }, confineMouseModeSetting = new SettingsEnumDropdown { @@ -66,36 +69,40 @@ namespace osu.Game.Overlays.Settings.Sections.Input Current = osuConfig.GetBindable(OsuSetting.MouseDisableButtons) }, }; + } - windowMode = config.GetBindable(FrameworkSetting.WindowMode); - windowMode.BindValueChanged(mode => confineMouseModeSetting.Alpha = mode.NewValue == WindowMode.Fullscreen ? 0 : 1, true); + protected override void LoadComplete() + { + base.LoadComplete(); - if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) + relativeMode.BindValueChanged(relative => localSensitivity.Disabled = !relative.NewValue, true); + + handlerSensitivity.BindValueChanged(val => { - rawInputToggle.Disabled = true; - sensitivityBindable.Disabled = true; - } - else + var disabled = localSensitivity.Disabled; + + localSensitivity.Disabled = false; + localSensitivity.Value = val.NewValue; + localSensitivity.Disabled = disabled; + }, true); + + localSensitivity.BindValueChanged(val => handlerSensitivity.Value = val.NewValue); + + windowMode.BindValueChanged(mode => { - rawInputToggle.ValueChanged += enabled => + var isFullscreen = mode.NewValue == WindowMode.Fullscreen; + + if (isFullscreen) { - // this is temporary until we support per-handler settings. - const string raw_mouse_handler = @"OsuTKRawMouseHandler"; - const string standard_mouse_handlers = @"OsuTKMouseHandler MouseHandler"; - - ignoredInputHandlers.Value = enabled.NewValue ? standard_mouse_handlers : raw_mouse_handler; - }; - - ignoredInputHandlers = config.GetBindable(FrameworkSetting.IgnoredInputHandlers); - ignoredInputHandlers.ValueChanged += handler => + confineMouseModeSetting.Current.Disabled = true; + confineMouseModeSetting.TooltipText = "Not applicable in full screen mode"; + } + else { - bool raw = !handler.NewValue.Contains("Raw"); - rawInputToggle.Value = raw; - sensitivityBindable.Disabled = !raw; - }; - - ignoredInputHandlers.TriggerChange(); - } + confineMouseModeSetting.Current.Disabled = false; + confineMouseModeSetting.TooltipText = string.Empty; + } + }, true); } private class SensitivitySetting : SettingsSlider @@ -109,7 +116,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private class SensitivitySlider : OsuSliderBar { - public override string TooltipText => Current.Disabled ? "enable raw input to adjust sensitivity" : $"{base.TooltipText}x"; + public override string TooltipText => Current.Disabled ? "enable high precision mouse to adjust sensitivity" : $"{base.TooltipText}x"; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs new file mode 100644 index 0000000000..3e8da9f7d0 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs @@ -0,0 +1,109 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Handlers.Tablet; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Settings.Sections.Input +{ + internal class RotationPresetButtons : FillFlowContainer + { + private readonly ITabletHandler tabletHandler; + + private Bindable rotation; + + private const int height = 50; + + public RotationPresetButtons(ITabletHandler tabletHandler) + { + this.tabletHandler = tabletHandler; + + RelativeSizeAxes = Axes.X; + Height = height; + + for (int i = 0; i < 360; i += 90) + { + var presetRotation = i; + + Add(new RotationButton(i) + { + RelativeSizeAxes = Axes.X, + Height = height, + Width = 0.25f, + Text = $"{presetRotation}º", + Action = () => tabletHandler.Rotation.Value = presetRotation, + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + rotation = tabletHandler.Rotation.GetBoundCopy(); + rotation.BindValueChanged(val => + { + foreach (var b in Children.OfType()) + b.IsSelected = b.Preset == val.NewValue; + }, true); + } + + public class RotationButton : TriangleButton + { + [Resolved] + private OsuColour colours { get; set; } + + public readonly int Preset; + + public RotationButton(int preset) + { + Preset = preset; + } + + private bool isSelected; + + public bool IsSelected + { + get => isSelected; + set + { + if (value == isSelected) + return; + + isSelected = value; + + if (IsLoaded) + updateColour(); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateColour(); + } + + private void updateColour() + { + if (isSelected) + { + BackgroundColour = colours.BlueDark; + Triangles.ColourDark = colours.BlueDarker; + Triangles.ColourLight = colours.Blue; + } + else + { + BackgroundColour = colours.Gray4; + Triangles.ColourDark = colours.Gray5; + Triangles.ColourLight = colours.Gray6; + } + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs new file mode 100644 index 0000000000..412889d210 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs @@ -0,0 +1,197 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Handlers.Tablet; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Settings.Sections.Input +{ + public class TabletAreaSelection : CompositeDrawable + { + private readonly ITabletHandler handler; + + private Container tabletContainer; + private Container usableAreaContainer; + + private readonly Bindable areaOffset = new Bindable(); + private readonly Bindable areaSize = new Bindable(); + + private readonly BindableNumber rotation = new BindableNumber(); + + private readonly IBindable tablet = new Bindable(); + + private OsuSpriteText tabletName; + + private Box usableFill; + private OsuSpriteText usableAreaText; + + public TabletAreaSelection(ITabletHandler handler) + { + this.handler = handler; + + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = tabletContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = 5, + BorderThickness = 2, + BorderColour = colour.Gray3, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour.Gray1, + }, + usableAreaContainer = new Container + { + Origin = Anchor.Centre, + Children = new Drawable[] + { + usableFill = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.6f, + }, + new Box + { + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Height = 5, + }, + new Box + { + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 5, + }, + usableAreaText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.White, + Font = OsuFont.Default.With(size: 12), + Y = 10 + } + } + }, + tabletName = new OsuSpriteText + { + Padding = new MarginPadding(3), + Font = OsuFont.Default.With(size: 8) + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + areaOffset.BindTo(handler.AreaOffset); + areaOffset.BindValueChanged(val => + { + usableAreaContainer.MoveTo(val.NewValue, 100, Easing.OutQuint) + .OnComplete(_ => checkBounds()); // required as we are using SSDQ. + }, true); + + areaSize.BindTo(handler.AreaSize); + areaSize.BindValueChanged(val => + { + usableAreaContainer.ResizeTo(val.NewValue, 100, Easing.OutQuint) + .OnComplete(_ => checkBounds()); // required as we are using SSDQ. + + int x = (int)val.NewValue.X; + int y = (int)val.NewValue.Y; + int commonDivider = greatestCommonDivider(x, y); + + usableAreaText.Text = $"{(float)x / commonDivider}:{(float)y / commonDivider}"; + }, true); + + rotation.BindTo(handler.Rotation); + rotation.BindValueChanged(val => + { + tabletContainer.RotateTo(-val.NewValue, 800, Easing.OutQuint); + usableAreaContainer.RotateTo(val.NewValue, 100, Easing.OutQuint) + .OnComplete(_ => checkBounds()); // required as we are using SSDQ. + }, true); + + tablet.BindTo(handler.Tablet); + tablet.BindValueChanged(_ => Scheduler.AddOnce(updateTabletDetails)); + + updateTabletDetails(); + // initial animation should be instant. + FinishTransforms(true); + } + + private void updateTabletDetails() + { + tabletContainer.Size = tablet.Value?.Size ?? Vector2.Zero; + tabletName.Text = tablet.Value?.Name ?? string.Empty; + checkBounds(); + } + + private static int greatestCommonDivider(int a, int b) + { + while (b != 0) + { + int remainder = a % b; + a = b; + b = remainder; + } + + return a; + } + + [Resolved] + private OsuColour colour { get; set; } + + private void checkBounds() + { + if (tablet.Value == null) + return; + + var usableSsdq = usableAreaContainer.ScreenSpaceDrawQuad; + + bool isWithinBounds = tabletContainer.ScreenSpaceDrawQuad.Contains(usableSsdq.TopLeft + new Vector2(1)) && + tabletContainer.ScreenSpaceDrawQuad.Contains(usableSsdq.BottomRight - new Vector2(1)); + + usableFill.FadeColour(isWithinBounds ? colour.Blue : colour.RedLight, 100); + } + + protected override void Update() + { + base.Update(); + + if (!(tablet.Value?.Size is Vector2 size)) + return; + + float maxDimension = size.LengthFast; + + float fitX = maxDimension / (DrawWidth - Padding.Left - Padding.Right); + float fitY = maxDimension / DrawHeight; + + float adjust = MathF.Max(fitX, fitY); + + tabletContainer.Scale = new Vector2(1 / adjust); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs new file mode 100644 index 0000000000..d770c18878 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -0,0 +1,296 @@ +// 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.Input.Handlers.Tablet; +using osu.Framework.Platform; +using osu.Framework.Threading; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Settings.Sections.Input +{ + public class TabletSettings : SettingsSubsection + { + private readonly ITabletHandler tabletHandler; + + private readonly Bindable areaOffset = new Bindable(); + private readonly Bindable areaSize = new Bindable(); + private readonly IBindable tablet = new Bindable(); + + private readonly BindableNumber offsetX = new BindableNumber { MinValue = 0 }; + private readonly BindableNumber offsetY = new BindableNumber { MinValue = 0 }; + + private readonly BindableNumber sizeX = new BindableNumber { MinValue = 10 }; + private readonly BindableNumber sizeY = new BindableNumber { MinValue = 10 }; + + private readonly BindableNumber rotation = new BindableNumber { MinValue = 0, MaxValue = 360 }; + + [Resolved] + private GameHost host { get; set; } + + /// + /// Based on ultrawide monitor configurations. + /// + private const float largest_feasible_aspect_ratio = 21f / 9; + + private readonly BindableNumber aspectRatio = new BindableFloat(1) + { + MinValue = 1 / largest_feasible_aspect_ratio, + MaxValue = largest_feasible_aspect_ratio, + Precision = 0.01f, + }; + + private readonly BindableBool aspectLock = new BindableBool(); + + private ScheduledDelegate aspectRatioApplication; + + private FillFlowContainer mainSettings; + + private OsuSpriteText noTabletMessage; + + protected override string Header => "Tablet"; + + public TabletSettings(ITabletHandler tabletHandler) + { + this.tabletHandler = tabletHandler; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = "Enabled", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Current = tabletHandler.Enabled + }, + noTabletMessage = new OsuSpriteText + { + Text = "No tablet detected!", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS } + }, + mainSettings = new FillFlowContainer + { + Alpha = 0, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 8), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TabletAreaSelection(tabletHandler) + { + RelativeSizeAxes = Axes.X, + Height = 300, + }, + new DangerousSettingsButton + { + Text = "Reset to full area", + Action = () => + { + aspectLock.Value = false; + + areaOffset.SetDefault(); + areaSize.SetDefault(); + }, + }, + new SettingsButton + { + Text = "Conform to current game aspect ratio", + Action = () => + { + forceAspectRatio((float)host.Window.ClientSize.Width / host.Window.ClientSize.Height); + } + }, + new SettingsSlider + { + TransferValueOnCommit = true, + LabelText = "X Offset", + Current = offsetX + }, + new SettingsSlider + { + TransferValueOnCommit = true, + LabelText = "Y Offset", + Current = offsetY + }, + new SettingsSlider + { + TransferValueOnCommit = true, + LabelText = "Rotation", + Current = rotation + }, + new RotationPresetButtons(tabletHandler), + new SettingsSlider + { + TransferValueOnCommit = true, + LabelText = "Aspect Ratio", + Current = aspectRatio + }, + new SettingsCheckbox + { + LabelText = "Lock aspect ratio", + Current = aspectLock + }, + new SettingsSlider + { + TransferValueOnCommit = true, + LabelText = "Width", + Current = sizeX + }, + new SettingsSlider + { + TransferValueOnCommit = true, + LabelText = "Height", + Current = sizeY + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + rotation.BindTo(tabletHandler.Rotation); + + areaOffset.BindTo(tabletHandler.AreaOffset); + areaOffset.BindValueChanged(val => + { + offsetX.Value = val.NewValue.X; + offsetY.Value = val.NewValue.Y; + }, true); + + offsetX.BindValueChanged(val => areaOffset.Value = new Vector2(val.NewValue, areaOffset.Value.Y)); + offsetY.BindValueChanged(val => areaOffset.Value = new Vector2(areaOffset.Value.X, val.NewValue)); + + areaSize.BindTo(tabletHandler.AreaSize); + areaSize.BindValueChanged(val => + { + sizeX.Value = val.NewValue.X; + sizeY.Value = val.NewValue.Y; + }, true); + + sizeX.BindValueChanged(val => + { + areaSize.Value = new Vector2(val.NewValue, areaSize.Value.Y); + + aspectRatioApplication?.Cancel(); + aspectRatioApplication = Schedule(() => applyAspectRatio(sizeX)); + }); + + sizeY.BindValueChanged(val => + { + areaSize.Value = new Vector2(areaSize.Value.X, val.NewValue); + + aspectRatioApplication?.Cancel(); + aspectRatioApplication = Schedule(() => applyAspectRatio(sizeY)); + }); + + updateAspectRatio(); + aspectRatio.BindValueChanged(aspect => + { + aspectRatioApplication?.Cancel(); + aspectRatioApplication = Schedule(() => forceAspectRatio(aspect.NewValue)); + }); + + tablet.BindTo(tabletHandler.Tablet); + tablet.BindValueChanged(val => + { + Scheduler.AddOnce(toggleVisibility); + + var tab = val.NewValue; + + bool tabletFound = tab != null; + if (!tabletFound) + return; + + offsetX.MaxValue = tab.Size.X; + offsetX.Default = tab.Size.X / 2; + sizeX.Default = sizeX.MaxValue = tab.Size.X; + + offsetY.MaxValue = tab.Size.Y; + offsetY.Default = tab.Size.Y / 2; + sizeY.Default = sizeY.MaxValue = tab.Size.Y; + + areaSize.Default = new Vector2(sizeX.Default, sizeY.Default); + }, true); + } + + private void toggleVisibility() + { + bool tabletFound = tablet.Value != null; + + if (!tabletFound) + { + mainSettings.Hide(); + noTabletMessage.Show(); + return; + } + + mainSettings.Show(); + noTabletMessage.Hide(); + } + + private void applyAspectRatio(BindableNumber sizeChanged) + { + try + { + if (!aspectLock.Value) + { + float proposedAspectRatio = currentAspectRatio; + + if (proposedAspectRatio >= aspectRatio.MinValue && proposedAspectRatio <= aspectRatio.MaxValue) + { + // aspect ratio was in a valid range. + updateAspectRatio(); + return; + } + } + + // if lock is applied (or the specified values were out of range) aim to adjust the axis the user was not adjusting to conform. + if (sizeChanged == sizeX) + sizeY.Value = (int)(areaSize.Value.X / aspectRatio.Value); + else + sizeX.Value = (int)(areaSize.Value.Y * aspectRatio.Value); + } + finally + { + // cancel any event which may have fired while updating variables as a result of aspect ratio limitations. + // this avoids a potential feedback loop. + aspectRatioApplication?.Cancel(); + } + } + + private void forceAspectRatio(float aspectRatio) + { + aspectLock.Value = false; + + int proposedHeight = (int)(sizeX.Value / aspectRatio); + + if (proposedHeight < sizeY.MaxValue) + sizeY.Value = proposedHeight; + else + sizeX.Value = (int)(sizeY.Value * aspectRatio); + + updateAspectRatio(); + + aspectRatioApplication?.Cancel(); + aspectLock.Value = true; + } + + private void updateAspectRatio() => aspectRatio.Value = currentAspectRatio; + + private float currentAspectRatio => sizeX.Value / sizeY.Value; + } +} diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index b43453f53d..6e99891794 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -1,28 +1,106 @@ // 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.Sprites; +using osu.Framework.Input.Handlers; +using osu.Framework.Input.Handlers.Joystick; +using osu.Framework.Input.Handlers.Midi; +using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Platform; using osu.Game.Overlays.Settings.Sections.Input; namespace osu.Game.Overlays.Settings.Sections { public class InputSection : SettingsSection { + private readonly KeyBindingPanel keyConfig; + public override string Header => "Input"; + [Resolved] + private GameHost host { get; set; } + public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.Keyboard }; public InputSection(KeyBindingPanel keyConfig) + { + this.keyConfig = keyConfig; + } + + [BackgroundDependencyLoader] + private void load() { Children = new Drawable[] { - new MouseSettings(), - new KeyboardSettings(keyConfig), + new BindingSettings(keyConfig), }; + + foreach (var handler in host.AvailableInputHandlers) + { + var handlerSection = createSectionFor(handler); + + if (handlerSection != null) + Add(handlerSection); + } + } + + private SettingsSubsection createSectionFor(InputHandler handler) + { + SettingsSubsection section; + + switch (handler) + { + // ReSharper disable once SuspiciousTypeConversion.Global (net standard fuckery) + case ITabletHandler th: + section = new TabletSettings(th); + break; + + case MouseHandler mh: + section = new MouseSettings(mh); + break; + + // whitelist the handlers which should be displayed to avoid any weird cases of users touching settings they shouldn't. + case JoystickHandler _: + case MidiHandler _: + section = new HandlerSection(handler); + break; + + default: + return null; + } + + return section; + } + + private class HandlerSection : SettingsSubsection + { + private readonly InputHandler handler; + + public HandlerSection(InputHandler handler) + { + this.handler = handler; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = "Enabled", + Current = handler.Enabled + }, + }; + } + + protected override string Header => handler.Description; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs new file mode 100644 index 0000000000..349a112477 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs @@ -0,0 +1,133 @@ +// 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.Graphics; +using System.IO; +using osu.Framework.Allocation; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Screens; +using osuTK; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Screens; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public abstract class DirectorySelectScreen : OsuScreen + { + private TriangleButton selectionButton; + + private DirectorySelector directorySelector; + + /// + /// Text to display in the header to inform the user of what they are selecting. + /// + public abstract LocalisableString HeaderText { get; } + + /// + /// Called upon selection of a directory by the user. + /// + /// The selected directory + protected abstract void OnSelection(DirectoryInfo directory); + + /// + /// Whether the current directory is considered to be valid and can be selected. + /// + /// The current directory. + /// Whether the selected directory is considered valid. + protected virtual bool IsValidDirectory(DirectoryInfo info) => true; + + /// + /// The path at which to start selection from. + /// + protected virtual DirectoryInfo InitialPath => null; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChild = new Container + { + Masking = true, + CornerRadius = 10, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.5f, 0.8f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeafoamDark + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new OsuTextFlowContainer(cp => + { + cp.Font = OsuFont.Default.With(size: 24); + }) + { + Text = HeaderText.ToString(), + TextAnchor = Anchor.TopCentre, + Margin = new MarginPadding(10), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + }, + new Drawable[] + { + directorySelector = new DirectorySelector + { + RelativeSizeAxes = Axes.Both, + } + }, + new Drawable[] + { + selectionButton = new TriangleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + Margin = new MarginPadding(10), + Text = "Select directory", + Action = () => OnSelection(directorySelector.CurrentPath.Value) + }, + } + } + } + } + }; + } + + protected override void LoadComplete() + { + if (InitialPath != null) + directorySelector.CurrentPath.Value = InitialPath; + + directorySelector.CurrentPath.BindValueChanged(e => selectionButton.Enabled.Value = e.NewValue != null && IsValidDirectory(e.NewValue), true); + base.LoadComplete(); + } + + public override void OnSuspending(IScreen next) + { + base.OnSuspending(next); + + this.FadeOut(250); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index 848ce381a9..a38ca81e23 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Scoring; using osu.Game.Skinning; @@ -29,9 +30,9 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance private TriangleButton undeleteButton; [BackgroundDependencyLoader(permitNulls: true)] - private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, DialogOverlay dialogOverlay) + private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, [CanBeNull] StableImportManager stableImportManager, DialogOverlay dialogOverlay) { - if (beatmaps.SupportsImportFromStable) + if (stableImportManager?.SupportsImportFromStable == true) { Add(importBeatmapsButton = new SettingsButton { @@ -39,7 +40,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Action = () => { importBeatmapsButton.Enabled.Value = false; - beatmaps.ImportFromStableAsync().ContinueWith(t => Schedule(() => importBeatmapsButton.Enabled.Value = true)); + stableImportManager.ImportFromStableAsync(StableContent.Beatmaps).ContinueWith(t => Schedule(() => importBeatmapsButton.Enabled.Value = true)); } }); } @@ -57,7 +58,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance } }); - if (scores.SupportsImportFromStable) + if (stableImportManager?.SupportsImportFromStable == true) { Add(importScoresButton = new SettingsButton { @@ -65,7 +66,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Action = () => { importScoresButton.Enabled.Value = false; - scores.ImportFromStableAsync().ContinueWith(t => Schedule(() => importScoresButton.Enabled.Value = true)); + stableImportManager.ImportFromStableAsync(StableContent.Scores).ContinueWith(t => Schedule(() => importScoresButton.Enabled.Value = true)); } }); } @@ -83,7 +84,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance } }); - if (skins.SupportsImportFromStable) + if (stableImportManager?.SupportsImportFromStable == true) { Add(importSkinsButton = new SettingsButton { @@ -91,7 +92,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Action = () => { importSkinsButton.Enabled.Value = false; - skins.ImportFromStableAsync().ContinueWith(t => Schedule(() => importSkinsButton.Enabled.Value = true)); + stableImportManager.ImportFromStableAsync(StableContent.Skins).ContinueWith(t => Schedule(() => importSkinsButton.Enabled.Value = true)); } }); } @@ -111,7 +112,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance if (collectionManager != null) { - if (collectionManager.SupportsImportFromStable) + if (stableImportManager?.SupportsImportFromStable == true) { Add(importCollectionsButton = new SettingsButton { @@ -119,7 +120,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance Action = () => { importCollectionsButton.Enabled.Value = false; - collectionManager.ImportFromStableAsync().ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true)); + stableImportManager.ImportFromStableAsync(StableContent.Collections).ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true)); } }); } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs index ad540e3691..1a60ab0638 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs @@ -4,24 +4,19 @@ using System; using System.IO; using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Screens; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Screens; -using osuTK; namespace osu.Game.Overlays.Settings.Sections.Maintenance { - public class MigrationSelectScreen : OsuScreen + public class MigrationSelectScreen : DirectorySelectScreen { - private DirectorySelector directorySelector; + [Resolved] + private Storage storage { get; set; } + + protected override DirectoryInfo InitialPath => new DirectoryInfo(storage.GetFullPath(string.Empty)).Parent; public override bool AllowExternalScreenChange => false; @@ -29,84 +24,11 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance public override bool HideOverlaysOnEnter => true; - [BackgroundDependencyLoader(true)] - private void load(OsuGame game, Storage storage, OsuColour colours) + public override LocalisableString HeaderText => "Please select a new location"; + + protected override void OnSelection(DirectoryInfo directory) { - game?.Toolbar.Hide(); - - // begin selection in the parent directory of the current storage location - var initialPath = new DirectoryInfo(storage.GetFullPath(string.Empty)).Parent?.FullName; - - InternalChild = new Container - { - Masking = true, - CornerRadius = 10, - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(0.5f, 0.8f), - Children = new Drawable[] - { - new Box - { - Colour = colours.GreySeafoamDark, - RelativeSizeAxes = Axes.Both, - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Relative, 0.8f), - new Dimension(), - }, - Content = new[] - { - new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "Please select a new location", - Font = OsuFont.Default.With(size: 40) - }, - }, - new Drawable[] - { - directorySelector = new DirectorySelector(initialPath) - { - RelativeSizeAxes = Axes.Both, - } - }, - new Drawable[] - { - new TriangleButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 300, - Text = "Begin folder migration", - Action = start - }, - } - } - } - } - }; - } - - public override void OnSuspending(IScreen next) - { - base.OnSuspending(next); - - this.FadeOut(250); - } - - private void start() - { - var target = directorySelector.CurrentPath.Value; + var target = directory; try { diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs new file mode 100644 index 0000000000..904c9deaae --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectoryLocationDialog.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Screens; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class StableDirectoryLocationDialog : PopupDialog + { + [Resolved] + private OsuGame game { get; set; } + + public StableDirectoryLocationDialog(TaskCompletionSource taskCompletionSource) + { + HeaderText = "Failed to automatically locate an osu!stable installation."; + BodyText = "An existing install could not be located. If you know where it is, you can help locate it."; + Icon = FontAwesome.Solid.QuestionCircle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = "Sure! I know where it is located!", + Action = () => Schedule(() => game.PerformFromScreen(screen => screen.Push(new StableDirectorySelectScreen(taskCompletionSource)))) + }, + new PopupDialogCancelButton + { + Text = "Actually I don't have osu!stable installed.", + Action = () => taskCompletionSource.TrySetCanceled() + } + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs new file mode 100644 index 0000000000..4aea05fb14 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/StableDirectorySelectScreen.cs @@ -0,0 +1,39 @@ +// 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; +using System.Threading.Tasks; +using osu.Framework.Localisation; +using osu.Framework.Screens; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class StableDirectorySelectScreen : DirectorySelectScreen + { + private readonly TaskCompletionSource taskCompletionSource; + + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.Disabled; + + protected override bool IsValidDirectory(DirectoryInfo info) => info?.GetFiles("osu!.*.cfg").Any() ?? false; + + public override LocalisableString HeaderText => "Please select your osu!stable install location"; + + public StableDirectorySelectScreen(TaskCompletionSource taskCompletionSource) + { + this.taskCompletionSource = taskCompletionSource; + } + + protected override void OnSelection(DirectoryInfo directory) + { + taskCompletionSource.TrySetResult(directory.FullName); + this.Exit(); + } + + public override bool OnExiting(IScreen next) + { + taskCompletionSource.TrySetCanceled(); + return base.OnExiting(next); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 7c8309fd56..316837d27d 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -178,7 +179,7 @@ namespace osu.Game.Overlays.Settings.Sections private class SkinDropdownControl : DropdownControl { - protected override string GenerateItemText(SkinInfo item) => item.ToString(); + protected override LocalisableString GenerateItemText(SkinInfo item) => item.ToString(); } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 95e2e9da30..5f703ed5a4 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -2,8 +2,11 @@ // 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.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Users; namespace osu.Game.Overlays.Settings.Sections.UserInterface { @@ -11,9 +14,15 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { protected override string Header => "Main Menu"; + private IBindable user; + + private SettingsEnumDropdown backgroundSourceDropdown; + [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(OsuConfigManager config, IAPIProvider api) { + user = api.LocalUser.GetBoundCopy(); + Children = new Drawable[] { new SettingsCheckbox @@ -31,7 +40,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface LabelText = "Intro sequence", Current = config.GetBindable(OsuSetting.IntroSequence), }, - new SettingsEnumDropdown + backgroundSourceDropdown = new SettingsEnumDropdown { LabelText = "Background source", Current = config.GetBindable(OsuSetting.MenuBackgroundSource), @@ -43,5 +52,17 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + user.BindValueChanged(u => + { + const string not_supporter_note = "Changes to this setting will only apply with an active osu!supporter tag."; + + backgroundSourceDropdown.WarningText = u.NewValue?.IsSupporter != true ? not_supporter_note : string.Empty; + }, true); + } } } diff --git a/osu.Game/Overlays/Settings/SettingsCheckbox.cs b/osu.Game/Overlays/Settings/SettingsCheckbox.cs index 437b2e45b3..8b7ac80a5b 100644 --- a/osu.Game/Overlays/Settings/SettingsCheckbox.cs +++ b/osu.Game/Overlays/Settings/SettingsCheckbox.cs @@ -2,20 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { public class SettingsCheckbox : SettingsItem { - private string labelText; + private LocalisableString labelText; protected override Drawable CreateControl() => new OsuCheckbox(); - public override string LabelText + public override LocalisableString LabelText { get => labelText; - set => ((OsuCheckbox)Control).LabelText = labelText = value; + // checkbox doesn't properly support localisation yet. + set => ((OsuCheckbox)Control).LabelText = (labelText = value).ToString(); } } } diff --git a/osu.Game/Overlays/Settings/SettingsHeader.cs b/osu.Game/Overlays/Settings/SettingsHeader.cs index d8ec00bd99..a7f1cef74c 100644 --- a/osu.Game/Overlays/Settings/SettingsHeader.cs +++ b/osu.Game/Overlays/Settings/SettingsHeader.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -11,10 +12,10 @@ namespace osu.Game.Overlays.Settings { public class SettingsHeader : Container { - private readonly string heading; - private readonly string subheading; + private readonly LocalisableString heading; + private readonly LocalisableString subheading; - public SettingsHeader(string heading, string subheading) + public SettingsHeader(LocalisableString heading, LocalisableString subheading) { this.heading = heading; this.subheading = subheading; diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index af225889da..807916e7f6 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -5,19 +5,15 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osuTK.Graphics; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osuTK; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { @@ -35,11 +31,16 @@ namespace osu.Game.Overlays.Settings private SpriteText labelText; + private OsuTextFlowContainer warningText; + public bool ShowsDefaultIndicator = true; public string TooltipText { get; set; } - public virtual string LabelText + [Resolved] + private OsuColour colours { get; set; } + + public virtual LocalisableString LabelText { get => labelText?.Text ?? string.Empty; set @@ -56,11 +57,29 @@ namespace osu.Game.Overlays.Settings } } - [Obsolete("Use Current instead")] // Can be removed 20210406 - public Bindable Bindable + /// + /// Text to be displayed at the bottom of this . + /// Generally used to recommend the user change their setting as the current one is considered sub-optimal. + /// + public string WarningText { - get => Current; - set => Current = value; + set + { + if (warningText == null) + { + // construct lazily for cases where the label is not needed (may be provided by the Control). + FlowContent.Add(warningText = new OsuTextFlowContainer + { + Colour = colours.Yellow, + Margin = new MarginPadding { Bottom = 5 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }); + } + + warningText.Alpha = string.IsNullOrWhiteSpace(value) ? 0 : 1; + warningText.Text = value; + } } public virtual Bindable Current @@ -69,7 +88,7 @@ namespace osu.Game.Overlays.Settings set => controlWithCurrent.Current = value; } - public virtual IEnumerable FilterTerms => Keywords == null ? new[] { LabelText } : new List(Keywords) { LabelText }.ToArray(); + public virtual IEnumerable FilterTerms => Keywords == null ? new[] { LabelText.ToString() } : new List(Keywords) { LabelText.ToString() }.ToArray(); public IEnumerable Keywords { get; set; } @@ -84,7 +103,7 @@ namespace osu.Game.Overlays.Settings protected SettingsItem() { - RestoreDefaultValueButton restoreDefaultButton; + RestoreDefaultValueButton restoreDefaultButton; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -92,13 +111,16 @@ namespace osu.Game.Overlays.Settings InternalChildren = new Drawable[] { - restoreDefaultButton = new RestoreDefaultValueButton(), + restoreDefaultButton = new RestoreDefaultValueButton(), FlowContent = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS }, - Child = Control = CreateControl() + Children = new[] + { + Control = CreateControl(), + }, }, }; @@ -110,7 +132,7 @@ namespace osu.Game.Overlays.Settings controlWithCurrent.Current.DisabledChanged += _ => updateDisabled(); if (ShowsDefaultIndicator) - restoreDefaultButton.Bindable = controlWithCurrent.Current; + restoreDefaultButton.Current = controlWithCurrent.Current; } } @@ -119,102 +141,5 @@ namespace osu.Game.Overlays.Settings if (labelText != null) labelText.Alpha = controlWithCurrent.Current.Disabled ? 0.3f : 1; } - - private class RestoreDefaultValueButton : Container, IHasTooltip - { - private Bindable bindable; - - public Bindable Bindable - { - get => bindable; - set - { - bindable = value; - bindable.ValueChanged += _ => UpdateState(); - bindable.DisabledChanged += _ => UpdateState(); - bindable.DefaultChanged += _ => UpdateState(); - UpdateState(); - } - } - - private Color4 buttonColour; - - private bool hovering; - - public RestoreDefaultValueButton() - { - RelativeSizeAxes = Axes.Y; - Width = SettingsPanel.CONTENT_MARGINS; - Alpha = 0f; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - buttonColour = colour.Yellow; - - Child = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - CornerRadius = 3, - Masking = true, - Colour = buttonColour, - EdgeEffect = new EdgeEffectParameters - { - Colour = buttonColour.Opacity(0.1f), - Type = EdgeEffectType.Glow, - Radius = 2, - }, - Size = new Vector2(0.33f, 0.8f), - Child = new Box { RelativeSizeAxes = Axes.Both }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - UpdateState(); - } - - public string TooltipText => "revert to default"; - - protected override bool OnClick(ClickEvent e) - { - if (bindable != null && !bindable.Disabled) - bindable.SetDefault(); - return true; - } - - protected override bool OnHover(HoverEvent e) - { - hovering = true; - UpdateState(); - return false; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - hovering = false; - UpdateState(); - } - - public void SetButtonColour(Color4 buttonColour) - { - this.buttonColour = buttonColour; - UpdateState(); - } - - public void UpdateState() - { - if (bindable == null) - return; - - this.FadeTo(bindable.IsDefault ? 0f : - hovering && !bindable.Disabled ? 1f : 0.65f, 200, Easing.OutQuint); - this.FadeColour(bindable.Disabled ? Color4.Gray : buttonColour, 200, Easing.OutQuint); - } - } } -} +} \ No newline at end of file diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index 1b82d973e9..6abf6283b9 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -8,10 +8,12 @@ using osu.Game.Graphics.Sprites; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Testing; using osu.Game.Graphics; namespace osu.Game.Overlays.Settings { + [ExcludeFromDynamicCompile] public abstract class SettingsSubsection : FillFlowContainer, IHasFilterableChildren { protected override Container Content => FlowContent; diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 7bd84dbc6c..8c21880cc6 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -10,14 +10,16 @@ using osuTK.Graphics; using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Overlays { public class SettingsOverlay : SettingsPanel, INamedOverlayComponent { public string IconTexture => "Icons/Hexacons/settings"; - public string Title => "settings"; - public string Description => "change the way osu! behaves"; + public LocalisableString Title => SettingsStrings.HeaderTitle; + public LocalisableString Description => SettingsStrings.HeaderDescription; protected override IEnumerable CreateSections() => new SettingsSection[] { diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index f1270f750e..eae828c142 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -10,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Graphics; @@ -27,7 +28,7 @@ namespace osu.Game.Overlays private const float sidebar_width = Sidebar.DEFAULT_WIDTH; - protected const float WIDTH = 400; + public const float WIDTH = 400; protected Container ContentContainer; @@ -49,8 +50,6 @@ namespace osu.Game.Overlays private readonly bool showSidebar; - protected Box Background; - protected SettingsPanel(bool showSidebar) { this.showSidebar = showSidebar; @@ -63,13 +62,13 @@ namespace osu.Game.Overlays [BackgroundDependencyLoader] private void load() { - InternalChild = ContentContainer = new Container + InternalChild = ContentContainer = new NonMaskedContent { Width = WIDTH, RelativeSizeAxes = Axes.Y, Children = new Drawable[] { - Background = new Box + new Box { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, @@ -165,7 +164,7 @@ namespace osu.Game.Overlays { base.PopOut(); - ContentContainer.MoveToX(-WIDTH, TRANSITION_LENGTH, Easing.OutQuint); + ContentContainer.MoveToX(-WIDTH + ExpandedPosition, TRANSITION_LENGTH, Easing.OutQuint); Sidebar?.MoveToX(-sidebar_width, TRANSITION_LENGTH, Easing.OutQuint); this.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); @@ -191,7 +190,13 @@ namespace osu.Game.Overlays Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 }; } - protected class SettingsSectionsContainer : SectionsContainer + private class NonMaskedContent : Container + { + // masking breaks the pan-out transform with nested sub-settings panels. + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + } + + public class SettingsSectionsContainer : SectionsContainer { public SearchContainer SearchContainer; diff --git a/osu.Game/Overlays/Toolbar/Toolbar.cs b/osu.Game/Overlays/Toolbar/Toolbar.cs index 393e349bd0..3d88171ba7 100644 --- a/osu.Game/Overlays/Toolbar/Toolbar.cs +++ b/osu.Game/Overlays/Toolbar/Toolbar.cs @@ -13,14 +13,22 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Input.Events; using osu.Game.Rulesets; +using osu.Framework.Input.Bindings; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class Toolbar : VisibilityContainer + public class Toolbar : VisibilityContainer, IKeyBindingHandler { public const float HEIGHT = 40; public const float TOOLTIP_HEIGHT = 30; + /// + /// Whether the user hid this with . + /// In this state, automatic toggles should not occur, respecting the user's preference to have no toolbar. + /// + private bool hiddenByUser; + public Action OnHome; private ToolbarUserButton userButton; @@ -30,13 +38,22 @@ namespace osu.Game.Overlays.Toolbar protected readonly IBindable OverlayActivationMode = new Bindable(OverlayActivation.All); - // Toolbar components like RulesetSelector should receive keyboard input events even when the toolbar is hidden. + // Toolbar and its components need keyboard input even when hidden. public override bool PropagateNonPositionalInputSubTree => true; public Toolbar() { RelativeSizeAxes = Axes.X; Size = new Vector2(1, HEIGHT); + AlwaysPresent = true; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + // this only needed to be set for the initial LoadComplete/Update, so layout completes and gets buttons in a state they can correctly handle keyboard input for hotkeys. + AlwaysPresent = false; } [BackgroundDependencyLoader(true)] @@ -75,6 +92,7 @@ namespace osu.Game.Overlays.Toolbar new ToolbarBeatmapListingButton(), new ToolbarChatButton(), new ToolbarSocialButton(), + new ToolbarWikiButton(), new ToolbarMusicButton(), //new ToolbarButton //{ @@ -133,7 +151,9 @@ namespace osu.Game.Overlays.Toolbar protected override void UpdateState(ValueChangedEvent state) { - if (state.NewValue == Visibility.Visible && OverlayActivationMode.Value == OverlayActivation.Disabled) + bool blockShow = hiddenByUser || OverlayActivationMode.Value == OverlayActivation.Disabled; + + if (state.NewValue == Visibility.Visible && blockShow) { State.Value = Visibility.Hidden; return; @@ -155,5 +175,25 @@ namespace osu.Game.Overlays.Toolbar this.MoveToY(-DrawSize.Y, transition_time, Easing.OutQuint); this.FadeOut(transition_time, Easing.InQuint); } + + public bool OnPressed(GlobalAction action) + { + if (OverlayActivationMode.Value == OverlayActivation.Disabled) + return false; + + switch (action) + { + case GlobalAction.ToggleToolbar: + hiddenByUser = State.Value == Visibility.Visible; // set before toggling to allow the operation to always succeed. + ToggleVisibility(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 83f2bdf6cb..1933422dd9 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -12,6 +13,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; @@ -43,19 +45,19 @@ namespace osu.Game.Overlays.Toolbar Texture = textures.Get(texture), }); - public string Text + public LocalisableString Text { get => DrawableText.Text; set => DrawableText.Text = value; } - public string TooltipMain + public LocalisableString TooltipMain { get => tooltip1.Text; set => tooltip1.Text = value; } - public string TooltipSub + public LocalisableString TooltipSub { get => tooltip2.Text; set => tooltip2.Text = value; @@ -127,9 +129,9 @@ namespace osu.Game.Overlays.Toolbar { Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.Both, // stops us being considered in parent's autosize - Anchor = TooltipAnchor.HasFlag(Anchor.x0) ? Anchor.BottomLeft : Anchor.BottomRight, + Anchor = TooltipAnchor.HasFlagFast(Anchor.x0) ? Anchor.BottomLeft : Anchor.BottomRight, Origin = TooltipAnchor, - Position = new Vector2(TooltipAnchor.HasFlag(Anchor.x0) ? 5 : -5, 5), + Position = new Vector2(TooltipAnchor.HasFlagFast(Anchor.x0) ? 5 : -5, 5), Alpha = 0, Children = new Drawable[] { diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs index 905d5b44c6..9ca105ee7f 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.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.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -13,6 +14,8 @@ using osu.Framework.Input.Events; using osuTK.Input; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; namespace osu.Game.Overlays.Toolbar { @@ -20,6 +23,8 @@ namespace osu.Game.Overlays.Toolbar { protected Drawable ModeButtonLine { get; private set; } + private readonly Dictionary selectionSamples = new Dictionary(); + public ToolbarRulesetSelector() { RelativeSizeAxes = Axes.Y; @@ -27,7 +32,7 @@ namespace osu.Game.Overlays.Toolbar } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { AddRangeInternal(new[] { @@ -54,6 +59,9 @@ namespace osu.Game.Overlays.Toolbar } } }); + + foreach (var ruleset in Rulesets.AvailableRulesets) + selectionSamples[ruleset.ShortName] = audio.Samples.Get($"UI/ruleset-select-{ruleset.ShortName}"); } protected override void LoadComplete() @@ -61,20 +69,26 @@ namespace osu.Game.Overlays.Toolbar base.LoadComplete(); Current.BindDisabledChanged(disabled => this.FadeColour(disabled ? Color4.Gray : Color4.White, 300), true); - Current.BindValueChanged(_ => moveLineToCurrent(), true); + Current.BindValueChanged(_ => moveLineToCurrent()); + + // Scheduled to allow the button flow layout to be computed before the line position is updated + ScheduleAfterChildren(moveLineToCurrent); } private bool hasInitialPosition; - // Scheduled to allow the flow layout to be computed before the line position is updated - private void moveLineToCurrent() => ScheduleAfterChildren(() => + private void moveLineToCurrent() { if (SelectedTab != null) { ModeButtonLine.MoveToX(SelectedTab.DrawPosition.X, !hasInitialPosition ? 0 : 200, Easing.OutQuint); + + if (hasInitialPosition) + selectionSamples[SelectedTab.Value.ShortName]?.Play(); + hasInitialPosition = true; } - }); + } public override bool HandleNonPositionalInput => !Current.Disabled && base.HandleNonPositionalInput; diff --git a/osu.Game/Overlays/Toolbar/ToolbarWikiButton.cs b/osu.Game/Overlays/Toolbar/ToolbarWikiButton.cs new file mode 100644 index 0000000000..a521219b4f --- /dev/null +++ b/osu.Game/Overlays/Toolbar/ToolbarWikiButton.cs @@ -0,0 +1,19 @@ +// 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; + +namespace osu.Game.Overlays.Toolbar +{ + public class ToolbarWikiButton : ToolbarOverlayToggleButton + { + protected override Anchor TooltipAnchor => Anchor.TopRight; + + [BackgroundDependencyLoader(true)] + private void load(WikiOverlay wiki) + { + StateContainer = wiki; + } + } +} diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs index 3478f18a40..ae9c2eb394 100644 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs @@ -5,6 +5,9 @@ using System; using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Game.Extensions; using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Volume @@ -14,14 +17,42 @@ namespace osu.Game.Overlays.Volume public Func ActionRequested; public Func ScrollActionRequested; - public bool OnPressed(GlobalAction action) => - ActionRequested?.Invoke(action) ?? false; + private ScheduledDelegate keyRepeat; - public bool OnScroll(GlobalAction action, float amount, bool isPrecise) => - ScrollActionRequested?.Invoke(action, amount, isPrecise) ?? false; + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.DecreaseVolume: + case GlobalAction.IncreaseVolume: + keyRepeat?.Cancel(); + keyRepeat = this.BeginKeyRepeat(Scheduler, () => ActionRequested?.Invoke(action), 150); + return true; + + case GlobalAction.ToggleMute: + ActionRequested?.Invoke(action); + return true; + } + + return false; + } public void OnReleased(GlobalAction action) { + keyRepeat?.Cancel(); } + + protected override bool OnScroll(ScrollEvent e) + { + if (e.ScrollDelta.Y == 0) + return false; + + // forward any unhandled mouse scroll events to the volume control. + ScrollActionRequested?.Invoke(GlobalAction.IncreaseVolume, e.ScrollDelta.Y, e.IsPrecise); + return true; + } + + public bool OnScroll(GlobalAction action, float amount, bool isPrecise) => + ScrollActionRequested?.Invoke(action, amount, isPrecise) ?? false; } } diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index 5b997bbd05..a15076581e 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -203,7 +204,7 @@ namespace osu.Game.Overlays.Volume { displayVolume = value; - if (displayVolume > 0.99f) + if (displayVolume >= 0.995f) { text.Text = "MAX"; maxGlow.EffectColour = meterColour.Opacity(2f); @@ -225,7 +226,7 @@ namespace osu.Game.Overlays.Volume private set => Bindable.Value = value; } - private const double adjust_step = 0.05; + private const double adjust_step = 0.01; public void Increase(double amount = 1, bool isPrecise = false) => adjust(amount, isPrecise); public void Decrease(double amount = 1, bool isPrecise = false) => adjust(-amount, isPrecise); @@ -233,16 +234,43 @@ namespace osu.Game.Overlays.Volume // because volume precision is set to 0.01, this local is required to keep track of more precise adjustments and only apply when possible. private double scrollAccumulation; + private double accelerationModifier = 1; + + private const double max_acceleration = 5; + private const double acceleration_multiplier = 1.8; + + private ScheduledDelegate accelerationDebounce; + + private void resetAcceleration() => accelerationModifier = 1; + private void adjust(double delta, bool isPrecise) { - scrollAccumulation += delta * adjust_step * (isPrecise ? 0.1 : 1); + if (delta == 0) + return; + + // every adjust increment increases the rate at which adjustments happen up to a cutoff. + // this debounce will reset on inactivity. + accelerationDebounce?.Cancel(); + accelerationDebounce = Scheduler.AddDelayed(resetAcceleration, 150); + + delta *= accelerationModifier; + accelerationModifier = Math.Min(max_acceleration, accelerationModifier * acceleration_multiplier); var precision = Bindable.Precision; - while (Precision.AlmostBigger(Math.Abs(scrollAccumulation), precision)) + if (isPrecise) { - Volume += Math.Sign(scrollAccumulation) * precision; - scrollAccumulation = scrollAccumulation < 0 ? Math.Min(0, scrollAccumulation + precision) : Math.Max(0, scrollAccumulation - precision); + scrollAccumulation += delta * adjust_step * 0.1; + + while (Precision.AlmostBigger(Math.Abs(scrollAccumulation), precision)) + { + Volume += Math.Sign(scrollAccumulation) * precision; + scrollAccumulation = scrollAccumulation < 0 ? Math.Min(0, scrollAccumulation + precision) : Math.Max(0, scrollAccumulation - precision); + } + } + else + { + Volume += Math.Sign(delta) * Math.Max(precision, Math.Abs(delta * adjust_step)); } } diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs new file mode 100644 index 0000000000..acaaa523a2 --- /dev/null +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs @@ -0,0 +1,55 @@ +// 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 Markdig.Extensions.Yaml; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Game.Graphics.Containers.Markdown; +using osu.Game.Online.API; + +namespace osu.Game.Overlays.Wiki.Markdown +{ + public class WikiMarkdownContainer : OsuMarkdownContainer + { + [Resolved] + private IAPIProvider api { get; set; } + + public string CurrentPath + { + set => DocumentUrl = value; + } + + protected override void AddMarkdownComponent(IMarkdownObject markdownObject, FillFlowContainer container, int level) + { + switch (markdownObject) + { + case YamlFrontMatterBlock yamlFrontMatterBlock: + container.Add(new WikiNoticeContainer(yamlFrontMatterBlock)); + break; + + case ParagraphBlock paragraphBlock: + // Check if paragraph only contains an image + if (paragraphBlock.Inline.Count() == 1 && paragraphBlock.Inline.FirstChild is LinkInline { IsImage: true } linkInline) + { + container.Add(new WikiMarkdownImageBlock(linkInline)); + return; + } + + break; + } + + base.AddMarkdownComponent(markdownObject, container, level); + } + + public override MarkdownTextFlowContainer CreateTextFlow() => new WikiMarkdownTextFlowContainer(); + + private class WikiMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer + { + protected override void AddImage(LinkInline linkInline) => AddDrawable(new WikiMarkdownImage(linkInline)); + } + } +} diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs new file mode 100644 index 0000000000..c2115efeb5 --- /dev/null +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Syntax.Inlines; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Cursor; + +namespace osu.Game.Overlays.Wiki.Markdown +{ + public class WikiMarkdownImage : MarkdownImage, IHasTooltip + { + public string TooltipText { get; } + + public WikiMarkdownImage(LinkInline linkInline) + : base(linkInline.Url) + { + TooltipText = linkInline.Title; + } + + protected override ImageContainer CreateImageContainer(string url) + { + // The idea is replace "https://website.url/wiki/{path-to-image}" to "https://website.url/wiki/images/{path-to-image}" + // "/wiki/images/*" is route to fetch wiki image from osu!web server (see: https://github.com/ppy/osu-web/blob/4205eb66a4da86bdee7835045e4bf28c35456e04/routes/web.php#L289) + url = url.Replace("/wiki/", "/wiki/images/"); + + return base.CreateImageContainer(url); + } + } +} diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs new file mode 100644 index 0000000000..179762103a --- /dev/null +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Syntax.Inlines; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osuTK; + +namespace osu.Game.Overlays.Wiki.Markdown +{ + public class WikiMarkdownImageBlock : FillFlowContainer + { + [Resolved] + private IMarkdownTextComponent parentTextComponent { get; set; } + + private readonly LinkInline linkInline; + + public WikiMarkdownImageBlock(LinkInline linkInline) + { + this.linkInline = linkInline; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Vertical; + Spacing = new Vector2(0, 3); + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new WikiMarkdownImage(linkInline) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + parentTextComponent.CreateSpriteText().With(t => + { + t.Text = linkInline.Title; + t.Anchor = Anchor.TopCentre; + t.Origin = Anchor.TopCentre; + }), + }; + } + } +} diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs new file mode 100644 index 0000000000..421806eea8 --- /dev/null +++ b/osu.Game/Overlays/Wiki/Markdown/WikiNoticeContainer.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Extensions.Yaml; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; + +namespace osu.Game.Overlays.Wiki.Markdown +{ + public class WikiNoticeContainer : FillFlowContainer + { + private readonly bool isOutdated; + private readonly bool needsCleanup; + + public WikiNoticeContainer(YamlFrontMatterBlock yamlFrontMatterBlock) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Vertical; + + foreach (var line in yamlFrontMatterBlock.Lines) + { + switch (line.ToString()) + { + case "outdated: true": + isOutdated = true; + break; + + case "needs_cleanup: true": + needsCleanup = true; + break; + } + } + } + + [BackgroundDependencyLoader] + private void load() + { + // Reference : https://github.com/ppy/osu-web/blob/master/resources/views/wiki/_notice.blade.php and https://github.com/ppy/osu-web/blob/master/resources/lang/en/wiki.php + // TODO : add notice box for fallback translation, legal translation and outdated translation after implement wiki locale in the future. + if (isOutdated) + { + Add(new NoticeBox + { + Text = "The content on this page is incomplete or outdated. If you are able to help out, please consider updating the article!", + }); + } + else if (needsCleanup) + { + Add(new NoticeBox + { + Text = "This page does not meet the standards of the osu! wiki and needs to be cleaned up or rewritten. If you are able to help out, please consider updating the article!", + }); + } + } + + private class NoticeBox : Container + { + [Resolved] + private IMarkdownTextFlowComponent parentFlowComponent { get; set; } + + public string Text { get; set; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colour) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + MarkdownTextFlowContainer textFlow; + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + textFlow = parentFlowComponent.CreateTextFlow().With(t => + { + t.Colour = colour.Orange1; + t.Padding = new MarginPadding + { + Vertical = 10, + Horizontal = 15, + }; + }) + }; + + textFlow.AddText(Text); + } + } + } +} diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs new file mode 100644 index 0000000000..6b8cba48b4 --- /dev/null +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Overlays.Wiki +{ + public class WikiHeader : BreadcrumbControlOverlayHeader + { + private const string index_page_string = "index"; + private const string index_path = "Main_Page"; + + public readonly Bindable WikiPageData = new Bindable(); + + public Action ShowIndexPage; + public Action ShowParentPage; + + public WikiHeader() + { + TabControl.AddItem(index_page_string); + Current.Value = index_page_string; + + WikiPageData.BindValueChanged(onWikiPageChange); + Current.BindValueChanged(onCurrentChange); + } + + private void onWikiPageChange(ValueChangedEvent e) + { + if (e.NewValue == null) + return; + + TabControl.Clear(); + Current.Value = null; + + TabControl.AddItem(index_page_string); + + if (e.NewValue.Path == index_path) + { + Current.Value = index_page_string; + return; + } + + if (e.NewValue.Subtitle != null) + TabControl.AddItem(e.NewValue.Subtitle); + + TabControl.AddItem(e.NewValue.Title); + Current.Value = e.NewValue.Title; + } + + private void onCurrentChange(ValueChangedEvent e) + { + if (e.NewValue == TabControl.Items.LastOrDefault()) + return; + + if (e.NewValue == index_page_string) + { + ShowIndexPage?.Invoke(); + return; + } + + ShowParentPage?.Invoke(); + } + + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/wiki"); + + protected override OverlayTitle CreateTitle() => new WikiHeaderTitle(); + + private class WikiHeaderTitle : OverlayTitle + { + public WikiHeaderTitle() + { + Title = "wiki"; + Description = "knowledge base"; + IconTexture = "Icons/Hexacons/wiki"; + } + } + } +} diff --git a/osu.Game/Overlays/Wiki/WikiMainPage.cs b/osu.Game/Overlays/Wiki/WikiMainPage.cs new file mode 100644 index 0000000000..c4c0b83ef4 --- /dev/null +++ b/osu.Game/Overlays/Wiki/WikiMainPage.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using HtmlAgilityPack; +using osu.Framework.Allocation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Overlays.Wiki +{ + public class WikiMainPage : FillFlowContainer + { + public string Markdown; + + public WikiMainPage() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + var html = new HtmlDocument(); + html.LoadHtml(Markdown); + + var panels = createPanels(html).ToArray(); + + Children = new Drawable[] + { + createBlurb(html), + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = Enumerable.Repeat(new Dimension(GridSizeMode.AutoSize), panels.Length).ToArray(), + Content = panels, + }, + }; + } + + private Container createBlurb(HtmlDocument html) + { + var blurbNode = html.DocumentNode.SelectSingleNode("//div[contains(@class, 'wiki-main-page__blurb')]"); + + return new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Vertical = 30, + }, + Child = new OsuSpriteText + { + Text = blurbNode.InnerText, + Font = OsuFont.GetFont(size: 12), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + }; + } + + private IEnumerable createPanels(HtmlDocument html) + { + var panelsNode = html.DocumentNode.SelectNodes("//div[contains(@class, 'wiki-main-page-panel')]").ToArray(); + + Debug.Assert(panelsNode.Length > 1); + + var i = 0; + + while (i < panelsNode.Length) + { + var isFullWidth = panelsNode[i].HasClass("wiki-main-page-panel--full"); + + if (isFullWidth) + { + yield return new Drawable[] + { + new WikiPanelContainer(panelsNode[i++].InnerText, true) + { + // This is required to fill up the space of "null" drawable below. + Width = 2, + }, + null, + }; + } + else + { + yield return new Drawable[] + { + new WikiPanelContainer(panelsNode[i++].InnerText), + i < panelsNode.Length ? new WikiPanelContainer(panelsNode[i++].InnerText) : null, + }; + } + } + } + } +} diff --git a/osu.Game/Overlays/Wiki/WikiPanelContainer.cs b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs new file mode 100644 index 0000000000..e1c00a955b --- /dev/null +++ b/osu.Game/Overlays/Wiki/WikiPanelContainer.cs @@ -0,0 +1,127 @@ +// 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 Markdig.Syntax; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers.Markdown; +using osu.Game.Online.API; +using osu.Game.Overlays.Wiki.Markdown; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Wiki +{ + public class WikiPanelContainer : Container + { + private WikiPanelMarkdownContainer panelContainer; + + private readonly string text; + + private readonly bool isFullWidth; + + public WikiPanelContainer(string text, bool isFullWidth = false) + { + this.text = text; + this.isFullWidth = isFullWidth; + + RelativeSizeAxes = Axes.X; + Padding = new MarginPadding(3); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, IAPIProvider api) + { + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 4, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(25), + Offset = new Vector2(0, 1), + Radius = 3, + }, + Child = new Box + { + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + }, + panelContainer = new WikiPanelMarkdownContainer(isFullWidth) + { + CurrentPath = $@"{api.WebsiteRootUrl}/wiki/", + Text = text, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + }; + } + + protected override void Update() + { + base.Update(); + Height = Math.Max(panelContainer.Height, Parent.DrawHeight); + } + + private class WikiPanelMarkdownContainer : WikiMarkdownContainer + { + private readonly bool isFullWidth; + + public WikiPanelMarkdownContainer(bool isFullWidth) + { + this.isFullWidth = isFullWidth; + + LineSpacing = 0; + DocumentPadding = new MarginPadding(30); + DocumentMargin = new MarginPadding(0); + } + + public override SpriteText CreateSpriteText() => base.CreateSpriteText().With(t => t.Font = t.Font.With(weight: FontWeight.Bold)); + + public override MarkdownTextFlowContainer CreateTextFlow() => base.CreateTextFlow().With(f => f.TextAnchor = Anchor.TopCentre); + + protected override MarkdownParagraph CreateParagraph(ParagraphBlock paragraphBlock, int level) + => base.CreateParagraph(paragraphBlock, level).With(p => p.Margin = new MarginPadding { Bottom = 10 }); + + protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new WikiPanelHeading(headingBlock) + { + IsFullWidth = isFullWidth, + }; + } + + private class WikiPanelHeading : OsuMarkdownHeading + { + public bool IsFullWidth; + + public WikiPanelHeading(HeadingBlock headingBlock) + : base(headingBlock) + { + Margin = new MarginPadding { Bottom = 40 }; + } + + public override MarkdownTextFlowContainer CreateTextFlow() => base.CreateTextFlow().With(f => + { + f.Anchor = Anchor.TopCentre; + f.Origin = Anchor.TopCentre; + f.TextAnchor = Anchor.TopCentre; + }); + + protected override FontWeight GetFontWeightByLevel(int level) => FontWeight.Light; + + protected override float GetFontSizeByLevel(int level) => base.GetFontSizeByLevel(IsFullWidth ? level : 3); + } + } +} diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs new file mode 100644 index 0000000000..af7bc40f17 --- /dev/null +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -0,0 +1,148 @@ +// 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 System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Wiki; +using osu.Game.Overlays.Wiki.Markdown; + +namespace osu.Game.Overlays +{ + public class WikiOverlay : OnlineOverlay + { + private const string index_path = @"main_page"; + + private readonly Bindable path = new Bindable(index_path); + + private readonly Bindable wikiData = new Bindable(); + + [Resolved] + private IAPIProvider api { get; set; } + + private GetWikiRequest request; + + private CancellationTokenSource cancellationToken; + + private bool displayUpdateRequired = true; + + public WikiOverlay() + : base(OverlayColourScheme.Orange, false) + { + } + + public void ShowPage(string pagePath = index_path) + { + path.Value = pagePath.Trim('/'); + Show(); + } + + protected override WikiHeader CreateHeader() => new WikiHeader + { + ShowIndexPage = () => ShowPage(), + ShowParentPage = showParentPage, + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + path.BindValueChanged(onPathChanged); + wikiData.BindTo(Header.WikiPageData); + } + + protected override void PopIn() + { + base.PopIn(); + + if (displayUpdateRequired) + { + path.TriggerChange(); + displayUpdateRequired = false; + } + } + + protected override void PopOutComplete() + { + base.PopOutComplete(); + displayUpdateRequired = true; + } + + protected void LoadDisplay(Drawable display) + { + ScrollFlow.ScrollToStart(); + LoadComponentAsync(display, loaded => + { + Child = loaded; + Loading.Hide(); + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + private void onPathChanged(ValueChangedEvent e) + { + cancellationToken?.Cancel(); + request?.Cancel(); + + request = new GetWikiRequest(e.NewValue); + + Loading.Show(); + + request.Success += response => Schedule(() => onSuccess(response)); + request.Failure += _ => Schedule(() => LoadDisplay(Empty())); + + api.PerformAsync(request); + } + + private void onSuccess(APIWikiPage response) + { + wikiData.Value = response; + + if (response.Layout == index_path) + { + LoadDisplay(new WikiMainPage + { + Markdown = response.Markdown, + Padding = new MarginPadding + { + Vertical = 20, + Horizontal = 50, + }, + }); + } + else + { + LoadDisplay(new WikiMarkdownContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + CurrentPath = $@"{api.WebsiteRootUrl}/wiki/{path.Value}/", + Text = response.Markdown, + DocumentMargin = new MarginPadding(0), + DocumentPadding = new MarginPadding + { + Vertical = 20, + Left = 30, + Right = 50, + }, + }); + } + } + + private void showParentPage() + { + var parentPath = string.Join("/", path.Value.Split('/').SkipLast(1)); + ShowPage(parentPath); + } + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + request?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/PerformFromMenuRunner.cs b/osu.Game/PerformFromMenuRunner.cs index a4179c94da..6f979b8dc8 100644 --- a/osu.Game/PerformFromMenuRunner.cs +++ b/osu.Game/PerformFromMenuRunner.cs @@ -5,14 +5,13 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Threading; -using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Notifications; +using osu.Game.Screens; using osu.Game.Screens.Menu; namespace osu.Game @@ -29,9 +28,6 @@ namespace osu.Game [Resolved] private DialogOverlay dialogOverlay { get; set; } - [Resolved] - private IBindable beatmap { get; set; } - [Resolved(canBeNull: true)] private OsuGame game { get; set; } @@ -81,27 +77,45 @@ namespace osu.Game game?.CloseAllOverlays(false); - // we may already be at the target screen type. + findValidTarget(current); + } + + private bool findValidTarget(IScreen current) + { var type = current.GetType(); - if (validScreens.Any(t => t.IsAssignableFrom(type)) && !beatmap.Disabled) + // check if we are already at a valid target screen. + if (validScreens.Any(t => t.IsAssignableFrom(type))) { finalAction(current); Cancel(); - return; + return true; } while (current != null) { + // if this has a sub stack, recursively check the screens within it. + if (current is IHasSubScreenStack currentSubScreen) + { + if (findValidTarget(currentSubScreen.SubScreenStack.CurrentScreen)) + { + // should be correct in theory, but currently untested/unused in existing implementations. + current.MakeCurrent(); + return true; + } + } + if (validScreens.Any(t => t.IsAssignableFrom(type))) { current.MakeCurrent(); - break; + return true; } current = current.GetParentScreen(); type = current?.GetType(); } + + return false; } /// diff --git a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs index ab9ccda9b9..f6abf259e8 100644 --- a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs +++ b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs @@ -3,6 +3,7 @@ using MessagePack; using Newtonsoft.Json; +using osu.Framework.Extensions.EnumExtensions; using osu.Game.Rulesets.Replays; using osuTK; @@ -31,19 +32,19 @@ namespace osu.Game.Replays.Legacy [JsonIgnore] [IgnoreMember] - public bool MouseLeft1 => ButtonState.HasFlag(ReplayButtonState.Left1); + public bool MouseLeft1 => ButtonState.HasFlagFast(ReplayButtonState.Left1); [JsonIgnore] [IgnoreMember] - public bool MouseRight1 => ButtonState.HasFlag(ReplayButtonState.Right1); + public bool MouseRight1 => ButtonState.HasFlagFast(ReplayButtonState.Right1); [JsonIgnore] [IgnoreMember] - public bool MouseLeft2 => ButtonState.HasFlag(ReplayButtonState.Left2); + public bool MouseLeft2 => ButtonState.HasFlagFast(ReplayButtonState.Left2); [JsonIgnore] [IgnoreMember] - public bool MouseRight2 => ButtonState.HasFlag(ReplayButtonState.Right2); + public bool MouseRight2 => ButtonState.HasFlagFast(ReplayButtonState.Right2); [Key(3)] public ReplayButtonState ButtonState; diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index a25dc3e6db..5780fe39fa 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -16,11 +16,6 @@ namespace osu.Game.Rulesets.Difficulty { public abstract class DifficultyCalculator { - /// - /// The length of each strain section. - /// - protected virtual int SectionLength => 400; - private readonly Ruleset ruleset; private readonly WorkingBeatmap beatmap; @@ -71,32 +66,14 @@ namespace osu.Game.Rulesets.Difficulty var difficultyHitObjects = SortObjects(CreateDifficultyHitObjects(beatmap, clockRate)).ToList(); - double sectionLength = SectionLength * clockRate; - - // The first object doesn't generate a strain, so we begin with an incremented section end - double currentSectionEnd = Math.Ceiling(beatmap.HitObjects.First().StartTime / sectionLength) * sectionLength; - - foreach (DifficultyHitObject h in difficultyHitObjects) + foreach (var hitObject in difficultyHitObjects) { - while (h.BaseObject.StartTime > currentSectionEnd) + foreach (var skill in skills) { - foreach (Skill s in skills) - { - s.SaveCurrentPeak(); - s.StartNewSectionFrom(currentSectionEnd); - } - - currentSectionEnd += sectionLength; + skill.ProcessInternal(hitObject); } - - foreach (Skill s in skills) - s.Process(h); } - // The peak strain will not be saved for the last section in the above loop - foreach (Skill s in skills) - s.SaveCurrentPeak(); - return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); } diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs index ebbffb5143..5edfb2207b 100644 --- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs +++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs @@ -21,10 +21,20 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing public readonly HitObject LastObject; /// - /// Amount of time elapsed between and . + /// Amount of time elapsed between and , adjusted by clockrate. /// public readonly double DeltaTime; + /// + /// Clockrate adjusted start time of . + /// + public readonly double StartTime; + + /// + /// Clockrate adjusted end time of . + /// + public readonly double EndTime; + /// /// Creates a new . /// @@ -36,6 +46,8 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing BaseObject = hitObject; LastObject = lastObject; DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate; + StartTime = hitObject.StartTime / clockRate; + EndTime = hitObject.GetEndTime() / clockRate; } } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 95117be073..9f0fb987a7 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -1,9 +1,7 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; @@ -11,123 +9,52 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Difficulty.Skills { /// - /// Used to processes strain values of s, keep track of strain levels caused by the processed objects - /// and to calculate a final difficulty value representing the difficulty of hitting all the processed objects. + /// A bare minimal abstract skill for fully custom skill implementations. /// public abstract class Skill { - /// - /// The peak strain for each section of the beatmap. - /// - public IReadOnlyList StrainPeaks => strainPeaks; - - /// - /// Strain values are multiplied by this number for the given skill. Used to balance the value of different skills between each other. - /// - protected abstract double SkillMultiplier { get; } - - /// - /// Determines how quickly strain decays for the given skill. - /// For example a value of 0.15 indicates that strain decays to 15% of its original value in one second. - /// - protected abstract double StrainDecayBase { get; } - - /// - /// The weight by which each strain value decays. - /// - protected virtual double DecayWeight => 0.9; - /// /// s that were processed previously. They can affect the strain values of the following objects. /// - protected readonly LimitedCapacityStack Previous = new LimitedCapacityStack(2); // Contained objects not used yet + protected readonly ReverseQueue Previous; /// - /// The current strain level. + /// Number of previous s to keep inside the queue. /// - protected double CurrentStrain { get; private set; } = 1; + protected virtual int HistoryLength => 1; /// /// Mods for use in skill calculations. /// protected IReadOnlyList Mods => mods; - private double currentSectionPeak = 1; // We also keep track of the peak strain level in the current section. - - private readonly List strainPeaks = new List(); - private readonly Mod[] mods; protected Skill(Mod[] mods) { this.mods = mods; + Previous = new ReverseQueue(HistoryLength + 1); } - /// - /// Process a and update current strain values accordingly. - /// - public void Process(DifficultyHitObject current) + internal void ProcessInternal(DifficultyHitObject current) { - CurrentStrain *= strainDecay(current.DeltaTime); - CurrentStrain += StrainValueOf(current) * SkillMultiplier; + while (Previous.Count > HistoryLength) + Previous.Dequeue(); - currentSectionPeak = Math.Max(CurrentStrain, currentSectionPeak); + Process(current); - Previous.Push(current); + Previous.Enqueue(current); } /// - /// Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty. + /// Process a . /// - public void SaveCurrentPeak() - { - if (Previous.Count > 0) - strainPeaks.Add(currentSectionPeak); - } + /// The to process. + protected abstract void Process(DifficultyHitObject current); /// - /// Sets the initial strain level for a new section. + /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// - /// The beginning of the new section in milliseconds. - public void StartNewSectionFrom(double time) - { - // The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries. - // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. - if (Previous.Count > 0) - currentSectionPeak = GetPeakStrain(time); - } - - /// - /// Retrieves the peak strain at a point in time. - /// - /// The time to retrieve the peak strain at. - /// The peak strain. - protected virtual double GetPeakStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].BaseObject.StartTime); - - /// - /// Returns the calculated difficulty value representing all processed s. - /// - public double DifficultyValue() - { - double difficulty = 0; - double weight = 1; - - // Difficulty is the weighted sum of the highest strains from every section. - // We're sorting from highest to lowest strain. - foreach (double strain in strainPeaks.OrderByDescending(d => d)) - { - difficulty += strain * weight; - weight *= DecayWeight; - } - - return difficulty; - } - - /// - /// Calculates the strain value of a . This value is affected by previously processed objects. - /// - protected abstract double StrainValueOf(DifficultyHitObject current); - - private double strainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000); + public abstract double DifficultyValue(); } } diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs new file mode 100644 index 0000000000..71cee36812 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -0,0 +1,135 @@ +// 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.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Difficulty.Skills +{ + /// + /// Used to processes strain values of s, keep track of strain levels caused by the processed objects + /// and to calculate a final difficulty value representing the difficulty of hitting all the processed objects. + /// + public abstract class StrainSkill : Skill + { + /// + /// Strain values are multiplied by this number for the given skill. Used to balance the value of different skills between each other. + /// + protected abstract double SkillMultiplier { get; } + + /// + /// Determines how quickly strain decays for the given skill. + /// For example a value of 0.15 indicates that strain decays to 15% of its original value in one second. + /// + protected abstract double StrainDecayBase { get; } + + /// + /// The weight by which each strain value decays. + /// + protected virtual double DecayWeight => 0.9; + + /// + /// The current strain level. + /// + protected double CurrentStrain { get; private set; } = 1; + + /// + /// The length of each strain section. + /// + protected virtual int SectionLength => 400; + + private double currentSectionPeak = 1; // We also keep track of the peak strain level in the current section. + + private double currentSectionEnd; + + private readonly List strainPeaks = new List(); + + protected StrainSkill(Mod[] mods) + : base(mods) + { + } + + /// + /// Process a and update current strain values accordingly. + /// + protected sealed override void Process(DifficultyHitObject current) + { + // The first object doesn't generate a strain, so we begin with an incremented section end + if (Previous.Count == 0) + currentSectionEnd = Math.Ceiling(current.StartTime / SectionLength) * SectionLength; + + while (current.StartTime > currentSectionEnd) + { + saveCurrentPeak(); + startNewSectionFrom(currentSectionEnd); + currentSectionEnd += SectionLength; + } + + CurrentStrain *= strainDecay(current.DeltaTime); + CurrentStrain += StrainValueOf(current) * SkillMultiplier; + + currentSectionPeak = Math.Max(CurrentStrain, currentSectionPeak); + } + + /// + /// Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty. + /// + private void saveCurrentPeak() + { + strainPeaks.Add(currentSectionPeak); + } + + /// + /// Sets the initial strain level for a new section. + /// + /// The beginning of the new section in milliseconds. + private void startNewSectionFrom(double time) + { + // The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries. + // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. + currentSectionPeak = GetPeakStrain(time); + } + + /// + /// Retrieves the peak strain at a point in time. + /// + /// The time to retrieve the peak strain at. + /// The peak strain. + protected virtual double GetPeakStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].StartTime); + + /// + /// Returns a live enumerable of the peak strains for each section of the beatmap, + /// including the peak of the current section. + /// + public IEnumerable GetCurrentStrainPeaks() => strainPeaks.Append(currentSectionPeak); + + /// + /// Returns the calculated difficulty value representing all s that have been processed up to this point. + /// + public sealed override double DifficultyValue() + { + double difficulty = 0; + double weight = 1; + + // Difficulty is the weighted sum of the highest strains from every section. + // We're sorting from highest to lowest strain. + foreach (double strain in GetCurrentStrainPeaks().OrderByDescending(d => d)) + { + difficulty += strain * weight; + weight *= DecayWeight; + } + + return difficulty; + } + + /// + /// Calculates the strain value of a . This value is affected by previously processed objects. + /// + protected abstract double StrainValueOf(DifficultyHitObject current); + + private double strainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000); + } +} diff --git a/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs b/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs deleted file mode 100644 index 1fc5abce90..0000000000 --- a/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs +++ /dev/null @@ -1,92 +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; -using System.Collections.Generic; - -namespace osu.Game.Rulesets.Difficulty.Utils -{ - /// - /// An indexed stack with limited depth. Indexing starts at the top of the stack. - /// - public class LimitedCapacityStack : IEnumerable - { - /// - /// The number of elements in the stack. - /// - public int Count { get; private set; } - - private readonly T[] array; - private readonly int capacity; - private int marker; // Marks the position of the most recently added item. - - /// - /// Constructs a new . - /// - /// The number of items the stack can hold. - public LimitedCapacityStack(int capacity) - { - if (capacity < 0) - throw new ArgumentOutOfRangeException(nameof(capacity)); - - this.capacity = capacity; - array = new T[capacity]; - marker = capacity; // Set marker to the end of the array, outside of the indexed range by one. - } - - /// - /// Retrieves the item at an index in the stack. - /// - /// The index of the item to retrieve. The top of the stack is returned at index 0. - public T this[int i] - { - get - { - if (i < 0 || i > Count - 1) - throw new ArgumentOutOfRangeException(nameof(i)); - - i += marker; - if (i > capacity - 1) - i -= capacity; - - return array[i]; - } - } - - /// - /// Pushes an item to this . - /// - /// The item to push. - public void Push(T item) - { - // Overwrite the oldest item instead of shifting every item by one with every addition. - if (marker == 0) - marker = capacity - 1; - else - --marker; - - array[marker] = item; - - if (Count < capacity) - ++Count; - } - - /// - /// Returns an enumerator which enumerates items in the history starting from the most recently added one. - /// - public IEnumerator GetEnumerator() - { - for (int i = marker; i < capacity; ++i) - yield return array[i]; - - if (Count == capacity) - { - for (int i = 0; i < marker; ++i) - yield return array[i]; - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } -} diff --git a/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs b/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs new file mode 100644 index 0000000000..57db9df3ca --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs @@ -0,0 +1,133 @@ +// 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; +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Difficulty.Utils +{ + /// + /// An indexed queue where items are indexed beginning from the most recently enqueued item. + /// Enqueuing an item pushes all existing indexes up by one and inserts the item at index 0. + /// Dequeuing an item removes the item from the highest index and returns it. + /// + public class ReverseQueue : IEnumerable + { + /// + /// The number of elements in the . + /// + public int Count { get; private set; } + + private T[] items; + private int capacity; + private int start; + + public ReverseQueue(int initialCapacity) + { + if (initialCapacity <= 0) + throw new ArgumentOutOfRangeException(nameof(initialCapacity)); + + items = new T[initialCapacity]; + capacity = initialCapacity; + start = 0; + Count = 0; + } + + /// + /// Retrieves the item at an index in the . + /// + /// The index of the item to retrieve. The most recently enqueued item is at index 0. + public T this[int index] + { + get + { + if (index < 0 || index > Count - 1) + throw new ArgumentOutOfRangeException(nameof(index)); + + int reverseIndex = Count - 1 - index; + return items[(start + reverseIndex) % capacity]; + } + } + + /// + /// Enqueues an item to this . + /// + /// The item to enqueue. + public void Enqueue(T item) + { + if (Count == capacity) + { + // Double the buffer size + var buffer = new T[capacity * 2]; + + // Copy items to new queue + for (int i = 0; i < Count; i++) + { + buffer[i] = items[(start + i) % capacity]; + } + + // Replace array with new buffer + items = buffer; + capacity *= 2; + start = 0; + } + + items[(start + Count) % capacity] = item; + Count++; + } + + /// + /// Dequeues the least recently enqueued item from the and returns it. + /// + /// The item dequeued from the . + public T Dequeue() + { + var item = items[start]; + start = (start + 1) % capacity; + Count--; + return item; + } + + /// + /// Clears the of all items. + /// + public void Clear() + { + start = 0; + Count = 0; + } + + /// + /// Returns an enumerator which enumerates items in the starting from the most recently enqueued item. + /// + public IEnumerator GetEnumerator() => new Enumerator(this); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public struct Enumerator : IEnumerator + { + private ReverseQueue reverseQueue; + private int currentIndex; + + internal Enumerator(ReverseQueue reverseQueue) + { + this.reverseQueue = reverseQueue; + currentIndex = -1; // The first MoveNext() should bring the iterator to 0 + } + + public bool MoveNext() => ++currentIndex < reverseQueue.Count; + + public void Reset() => currentIndex = -1; + + public readonly T Current => reverseQueue[currentIndex]; + + readonly object IEnumerator.Current => Current; + + public void Dispose() + { + reverseQueue = null; + } + } + } +} diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs new file mode 100644 index 0000000000..d208c7fe07 --- /dev/null +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -0,0 +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.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit +{ + /// + /// A ruleset-agnostic beatmap verifier that identifies issues in common metadata or mapping standards. + /// + public class BeatmapVerifier : IBeatmapVerifier + { + private readonly List checks = new List + { + // Resources + new CheckBackgroundPresence(), + new CheckBackgroundQuality(), + + // Audio + new CheckAudioPresence(), + new CheckAudioQuality(), + + // Compose + new CheckUnsnappedObjects(), + new CheckConcurrentObjects() + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + return checks.SelectMany(check => check.Run(context)); + } + } +} diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs new file mode 100644 index 0000000000..6feee82bda --- /dev/null +++ b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs @@ -0,0 +1,38 @@ +// 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; + +#nullable enable + +namespace osu.Game.Rulesets.Edit +{ + /// + /// Represents the context provided by the beatmap verifier to the checks it runs. + /// Contains information about what is being checked and how it should be checked. + /// + public class BeatmapVerifierContext + { + /// + /// The playable beatmap instance of the current beatmap. + /// + public readonly IBeatmap Beatmap; + + /// + /// The working beatmap instance of the current beatmap. + /// + public readonly IWorkingBeatmap WorkingBeatmap; + + /// + /// The difficulty level which the current beatmap is considered to be. + /// + public DifficultyRating InterpretedDifficulty; + + public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus) + { + Beatmap = beatmap; + WorkingBeatmap = workingBeatmap; + InterpretedDifficulty = difficultyRating; + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.cs new file mode 100644 index 0000000000..94c48c300a --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioPresence.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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckAudioPresence : CheckFilePresence + { + protected override CheckCategory Category => CheckCategory.Audio; + protected override string TypeOfFile => "audio"; + protected override string GetFilename(IBeatmap beatmap) => beatmap.Metadata?.AudioFile; + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs new file mode 100644 index 0000000000..70d11883b7 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioQuality.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckAudioQuality : ICheck + { + // This is a requirement as stated in the Ranking Criteria. + // See https://osu.ppy.sh/wiki/en/Ranking_Criteria#rules.4 + private const int max_bitrate = 192; + + // "A song's audio file /.../ must be of reasonable quality. Try to find the highest quality source file available" + // There not existing a version with a bitrate of 128 kbps or higher is extremely rare. + private const int min_bitrate = 128; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Too high or low audio bitrate"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooHighBitrate(this), + new IssueTemplateTooLowBitrate(this), + new IssueTemplateNoBitrate(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var audioFile = context.Beatmap.Metadata?.AudioFile; + if (audioFile == null) + yield break; + + var track = context.WorkingBeatmap.Track; + + if (track?.Bitrate == null || track.Bitrate.Value == 0) + yield return new IssueTemplateNoBitrate(this).Create(); + else if (track.Bitrate.Value > max_bitrate) + yield return new IssueTemplateTooHighBitrate(this).Create(track.Bitrate.Value); + else if (track.Bitrate.Value < min_bitrate) + yield return new IssueTemplateTooLowBitrate(this).Create(track.Bitrate.Value); + } + + public class IssueTemplateTooHighBitrate : IssueTemplate + { + public IssueTemplateTooHighBitrate(ICheck check) + : base(check, IssueType.Problem, "The audio bitrate ({0} kbps) exceeds {1} kbps.") + { + } + + public Issue Create(int bitrate) => new Issue(this, bitrate, max_bitrate); + } + + public class IssueTemplateTooLowBitrate : IssueTemplate + { + public IssueTemplateTooLowBitrate(ICheck check) + : base(check, IssueType.Problem, "The audio bitrate ({0} kbps) is lower than {1} kbps.") + { + } + + public Issue Create(int bitrate) => new Issue(this, bitrate, min_bitrate); + } + + public class IssueTemplateNoBitrate : IssueTemplate + { + public IssueTemplateNoBitrate(ICheck check) + : base(check, IssueType.Error, "The audio bitrate could not be retrieved.") + { + } + + public Issue Create() => new Issue(this); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.cs new file mode 100644 index 0000000000..067800b409 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundPresence.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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckBackgroundPresence : CheckFilePresence + { + protected override CheckCategory Category => CheckCategory.Resources; + protected override string TypeOfFile => "background"; + protected override string GetFilename(IBeatmap beatmap) => beatmap.Metadata?.BackgroundFile; + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs new file mode 100644 index 0000000000..085c558eaf --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckBackgroundQuality : ICheck + { + // These are the requirements as stated in the Ranking Criteria. + // See https://osu.ppy.sh/wiki/en/Ranking_Criteria#rules.5 + private const int min_width = 160; + private const int max_width = 2560; + private const int min_height = 120; + private const int max_height = 1440; + private const double max_filesize_mb = 2.5d; + + // It's usually possible to find a higher resolution of the same image if lower than these. + private const int low_width = 960; + private const int low_height = 540; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Resources, "Too high or low background resolution"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooHighResolution(this), + new IssueTemplateTooLowResolution(this), + new IssueTemplateTooUncompressed(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var backgroundFile = context.Beatmap.Metadata?.BackgroundFile; + if (backgroundFile == null) + yield break; + + var texture = context.WorkingBeatmap.Background; + if (texture == null) + yield break; + + if (texture.Width > max_width || texture.Height > max_height) + yield return new IssueTemplateTooHighResolution(this).Create(texture.Width, texture.Height); + + if (texture.Width < min_width || texture.Height < min_height) + yield return new IssueTemplateTooLowResolution(this).Create(texture.Width, texture.Height); + else if (texture.Width < low_width || texture.Height < low_height) + yield return new IssueTemplateLowResolution(this).Create(texture.Width, texture.Height); + + string storagePath = context.Beatmap.BeatmapInfo.BeatmapSet.GetPathForFile(backgroundFile); + double filesizeMb = context.WorkingBeatmap.GetStream(storagePath).Length / (1024d * 1024d); + + if (filesizeMb > max_filesize_mb) + yield return new IssueTemplateTooUncompressed(this).Create(filesizeMb); + } + + public class IssueTemplateTooHighResolution : IssueTemplate + { + public IssueTemplateTooHighResolution(ICheck check) + : base(check, IssueType.Problem, "The background resolution ({0} x {1}) exceeds {2} x {3}.") + { + } + + public Issue Create(double width, double height) => new Issue(this, width, height, max_width, max_height); + } + + public class IssueTemplateTooLowResolution : IssueTemplate + { + public IssueTemplateTooLowResolution(ICheck check) + : base(check, IssueType.Problem, "The background resolution ({0} x {1}) is lower than {2} x {3}.") + { + } + + public Issue Create(double width, double height) => new Issue(this, width, height, min_width, min_height); + } + + public class IssueTemplateLowResolution : IssueTemplate + { + public IssueTemplateLowResolution(ICheck check) + : base(check, IssueType.Warning, "The background resolution ({0} x {1}) is lower than {2} x {3}.") + { + } + + public Issue Create(double width, double height) => new Issue(this, width, height, low_width, low_height); + } + + public class IssueTemplateTooUncompressed : IssueTemplate + { + public IssueTemplateTooUncompressed(ICheck check) + : base(check, IssueType.Problem, "The background filesize ({0:0.##} MB) exceeds {1} MB.") + { + } + + public Issue Create(double actualMb) => new Issue(this, actualMb, max_filesize_mb); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs new file mode 100644 index 0000000000..ba5fbcf58d --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckConcurrentObjects.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckConcurrentObjects : ICheck + { + // We guarantee that the objects are either treated as concurrent or unsnapped when near the same beat divisor. + private const double ms_leniency = CheckUnsnappedObjects.UNSNAP_MS_THRESHOLD; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Compose, "Concurrent hitobjects"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateConcurrentSame(this), + new IssueTemplateConcurrentDifferent(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var hitObjects = context.Beatmap.HitObjects; + + for (int i = 0; i < hitObjects.Count - 1; ++i) + { + var hitobject = hitObjects[i]; + + for (int j = i + 1; j < hitObjects.Count; ++j) + { + var nextHitobject = hitObjects[j]; + + // Accounts for rulesets with hitobjects separated by columns, such as Mania. + // In these cases we only care about concurrent objects within the same column. + if ((hitobject as IHasColumn)?.Column != (nextHitobject as IHasColumn)?.Column) + continue; + + // Two hitobjects cannot be concurrent without also being concurrent with all objects in between. + // So if the next object is not concurrent, then we know no future objects will be either. + if (!areConcurrent(hitobject, nextHitobject)) + break; + + if (hitobject.GetType() == nextHitobject.GetType()) + yield return new IssueTemplateConcurrentSame(this).Create(hitobject, nextHitobject); + else + yield return new IssueTemplateConcurrentDifferent(this).Create(hitobject, nextHitobject); + } + } + } + + private bool areConcurrent(HitObject hitobject, HitObject nextHitobject) => nextHitobject.StartTime <= hitobject.GetEndTime() + ms_leniency; + + public abstract class IssueTemplateConcurrent : IssueTemplate + { + protected IssueTemplateConcurrent(ICheck check, string unformattedMessage) + : base(check, IssueType.Problem, unformattedMessage) + { + } + + public Issue Create(HitObject hitobject, HitObject nextHitobject) + { + var hitobjects = new List { hitobject, nextHitobject }; + return new Issue(hitobjects, this, hitobject.GetType().Name, nextHitobject.GetType().Name) + { + Time = nextHitobject.StartTime + }; + } + } + + public class IssueTemplateConcurrentSame : IssueTemplateConcurrent + { + public IssueTemplateConcurrentSame(ICheck check) + : base(check, "{0}s are concurrent here.") + { + } + } + + public class IssueTemplateConcurrentDifferent : IssueTemplateConcurrent + { + public IssueTemplateConcurrentDifferent(ICheck check) + : base(check, "{0} and {1} are concurrent here.") + { + } + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs new file mode 100644 index 0000000000..36a0bf8c5d --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckFilePresence.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public abstract class CheckFilePresence : ICheck + { + protected abstract CheckCategory Category { get; } + protected abstract string TypeOfFile { get; } + protected abstract string GetFilename(IBeatmap beatmap); + + public CheckMetadata Metadata => new CheckMetadata(Category, $"Missing {TypeOfFile}"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateNoneSet(this), + new IssueTemplateDoesNotExist(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var filename = GetFilename(context.Beatmap); + + if (filename == null) + { + yield return new IssueTemplateNoneSet(this).Create(TypeOfFile); + + yield break; + } + + // If the file is set, also make sure it still exists. + var storagePath = context.Beatmap.BeatmapInfo.BeatmapSet.GetPathForFile(filename); + if (storagePath != null) + yield break; + + yield return new IssueTemplateDoesNotExist(this).Create(TypeOfFile, filename); + } + + public class IssueTemplateNoneSet : IssueTemplate + { + public IssueTemplateNoneSet(ICheck check) + : base(check, IssueType.Problem, "No {0} has been set.") + { + } + + public Issue Create(string typeOfFile) => new Issue(this, typeOfFile); + } + + public class IssueTemplateDoesNotExist : IssueTemplate + { + public IssueTemplateDoesNotExist(ICheck check) + : base(check, IssueType.Problem, "The {0} file \"{1}\" does not exist.") + { + } + + public Issue Create(string typeOfFile, string filename) => new Issue(this, typeOfFile, filename); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs new file mode 100644 index 0000000000..ded1bb54ca --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckUnsnappedObjects.cs @@ -0,0 +1,99 @@ +// 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 osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckUnsnappedObjects : ICheck + { + public const double UNSNAP_MS_THRESHOLD = 2; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Timing, "Unsnapped hitobjects"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateLargeUnsnap(this), + new IssueTemplateSmallUnsnap(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var controlPointInfo = context.Beatmap.ControlPointInfo; + + foreach (var hitobject in context.Beatmap.HitObjects) + { + double startUnsnap = hitobject.StartTime - controlPointInfo.GetClosestSnappedTime(hitobject.StartTime); + string startPostfix = hitobject is IHasDuration ? "start" : ""; + foreach (var issue in getUnsnapIssues(hitobject, startUnsnap, hitobject.StartTime, startPostfix)) + yield return issue; + + if (hitobject is IHasRepeats hasRepeats) + { + for (int repeatIndex = 0; repeatIndex < hasRepeats.RepeatCount; ++repeatIndex) + { + double spanDuration = hasRepeats.Duration / (hasRepeats.RepeatCount + 1); + double repeatTime = hitobject.StartTime + spanDuration * (repeatIndex + 1); + double repeatUnsnap = repeatTime - controlPointInfo.GetClosestSnappedTime(repeatTime); + foreach (var issue in getUnsnapIssues(hitobject, repeatUnsnap, repeatTime, "repeat")) + yield return issue; + } + } + + if (hitobject is IHasDuration hasDuration) + { + double endUnsnap = hasDuration.EndTime - controlPointInfo.GetClosestSnappedTime(hasDuration.EndTime); + foreach (var issue in getUnsnapIssues(hitobject, endUnsnap, hasDuration.EndTime, "end")) + yield return issue; + } + } + } + + private IEnumerable getUnsnapIssues(HitObject hitobject, double unsnap, double time, string postfix = "") + { + if (Math.Abs(unsnap) >= UNSNAP_MS_THRESHOLD) + yield return new IssueTemplateLargeUnsnap(this).Create(hitobject, unsnap, time, postfix); + else if (Math.Abs(unsnap) >= 1) + yield return new IssueTemplateSmallUnsnap(this).Create(hitobject, unsnap, time, postfix); + + // We don't care about unsnaps < 1 ms, as all object ends have these due to the way SV works. + } + + public abstract class IssueTemplateUnsnap : IssueTemplate + { + protected IssueTemplateUnsnap(ICheck check, IssueType type) + : base(check, type, "{0} is unsnapped by {1:0.##} ms.") + { + } + + public Issue Create(HitObject hitobject, double unsnap, double time, string postfix = "") + { + string objectName = hitobject.GetType().Name; + if (!string.IsNullOrEmpty(postfix)) + objectName += " " + postfix; + + return new Issue(hitobject, this, objectName, unsnap) { Time = time }; + } + } + + public class IssueTemplateLargeUnsnap : IssueTemplateUnsnap + { + public IssueTemplateLargeUnsnap(ICheck check) + : base(check, IssueType.Problem) + { + } + } + + public class IssueTemplateSmallUnsnap : IssueTemplateUnsnap + { + public IssueTemplateSmallUnsnap(ICheck check) + : base(check, IssueType.Negligible) + { + } + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/CheckCategory.cs b/osu.Game/Rulesets/Edit/Checks/Components/CheckCategory.cs new file mode 100644 index 0000000000..ae943cfda9 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/CheckCategory.cs @@ -0,0 +1,61 @@ +// 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.Rulesets.Edit.Checks.Components +{ + /// + /// The category of an issue. + /// + public enum CheckCategory + { + /// + /// Anything to do with control points. + /// + Timing, + + /// + /// Anything to do with artist, title, creator, etc. + /// + Metadata, + + /// + /// Anything to do with non-audio files, e.g. background, skin, sprites, and video. + /// + Resources, + + /// + /// Anything to do with audio files, e.g. song and hitsounds. + /// + Audio, + + /// + /// Anything to do with files that don't fit into the above, e.g. unused, osu, or osb. + /// + Files, + + /// + /// Anything to do with hitobjects unrelated to spread. + /// + Compose, + + /// + /// Anything to do with difficulty levels or their progression. + /// + Spread, + + /// + /// Anything to do with variables like CS, OD, AR, HP, and global SV. + /// + Settings, + + /// + /// Anything to do with hitobject feedback. + /// + HitObjects, + + /// + /// Anything to do with storyboarding, breaks, video offset, etc. + /// + Events + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs b/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs new file mode 100644 index 0000000000..cebb2f5455 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/CheckMetadata.cs @@ -0,0 +1,24 @@ +// 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.Rulesets.Edit.Checks.Components +{ + public class CheckMetadata + { + /// + /// The category this check belongs to. E.g. , , or . + /// + public readonly CheckCategory Category; + + /// + /// Describes the issue(s) that this check looks for. Keep this brief, such that it fits into "No {description}". E.g. "Offscreen objects" / "Too short sliders". + /// + public readonly string Description; + + public CheckMetadata(CheckCategory category, string description) + { + Category = category; + Description = description; + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs b/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs new file mode 100644 index 0000000000..141de55f1d --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/ICheck.cs @@ -0,0 +1,29 @@ +// 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; + +namespace osu.Game.Rulesets.Edit.Checks.Components +{ + /// + /// A specific check that can be run on a beatmap to verify or find issues. + /// + public interface ICheck + { + /// + /// The metadata for this check. + /// + public CheckMetadata Metadata { get; } + + /// + /// All possible templates for issues that this check may return. + /// + public IEnumerable PossibleTemplates { get; } + + /// + /// Runs this check and returns any issues detected for the provided beatmap. + /// + /// The beatmap verifier context associated with the beatmap. + public IEnumerable Run(BeatmapVerifierContext context); + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/Issue.cs b/osu.Game/Rulesets/Edit/Checks/Components/Issue.cs new file mode 100644 index 0000000000..2bc9930e8f --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/Issue.cs @@ -0,0 +1,77 @@ +// 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.Game.Extensions; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Edit.Checks.Components +{ + public class Issue + { + /// + /// The time which this issue is associated with, if any, otherwise null. + /// + public double? Time; + + /// + /// The hitobjects which this issue is associated with. Empty by default. + /// + public IReadOnlyList HitObjects; + + /// + /// The template which this issue is using. This provides properties such as the , and the . + /// + public IssueTemplate Template; + + /// + /// The check that this issue originates from. + /// + public ICheck Check => Template.Check; + + /// + /// The arguments that give this issue its context, based on the . These are then substituted into the . + /// This could for instance include timestamps, which diff is being compared to, what some volume is, etc. + /// + public object[] Arguments; + + public Issue(IssueTemplate template, params object[] args) + { + Time = null; + HitObjects = Array.Empty(); + Template = template; + Arguments = args; + } + + public Issue(double? time, IssueTemplate template, params object[] args) + : this(template, args) + { + Time = time; + } + + public Issue(HitObject hitObject, IssueTemplate template, params object[] args) + : this(template, args) + { + Time = hitObject.StartTime; + HitObjects = new[] { hitObject }; + } + + public Issue(IEnumerable hitObjects, IssueTemplate template, params object[] args) + : this(template, args) + { + var hitObjectList = hitObjects.ToList(); + + Time = hitObjectList.FirstOrDefault()?.StartTime; + HitObjects = hitObjectList; + } + + public override string ToString() => Template.GetMessage(Arguments); + + public string GetEditorTimestamp() + { + return Time == null ? string.Empty : Time.Value.ToEditorFormattedString(); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/IssueTemplate.cs b/osu.Game/Rulesets/Edit/Checks/Components/IssueTemplate.cs new file mode 100644 index 0000000000..97df79ecd8 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/IssueTemplate.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Humanizer; +using osu.Framework.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Edit.Checks.Components +{ + public class IssueTemplate + { + private static readonly Color4 problem_red = new Colour4(1.0f, 0.4f, 0.4f, 1.0f); + private static readonly Color4 warning_yellow = new Colour4(1.0f, 0.8f, 0.2f, 1.0f); + private static readonly Color4 negligible_green = new Colour4(0.33f, 0.8f, 0.5f, 1.0f); + private static readonly Color4 error_gray = new Colour4(0.5f, 0.5f, 0.5f, 1.0f); + + /// + /// The check that this template originates from. + /// + public readonly ICheck Check; + + /// + /// The type of the issue. + /// + public readonly IssueType Type; + + /// + /// The unformatted message given when this issue is detected. + /// This gets populated later when an issue is constructed with this template. + /// E.g. "Inconsistent snapping (1/{0}) with [{1}] (1/{2})." + /// + public readonly string UnformattedMessage; + + public IssueTemplate(ICheck check, IssueType type, string unformattedMessage) + { + Check = check; + Type = type; + UnformattedMessage = unformattedMessage; + } + + /// + /// Returns the formatted message given the arguments used to format it. + /// + /// The arguments used to format the message. + public string GetMessage(params object[] args) => UnformattedMessage.FormatWith(args); + + /// + /// Returns the colour corresponding to the type of this issue. + /// + public Colour4 Colour + { + get + { + switch (Type) + { + case IssueType.Problem: + return problem_red; + + case IssueType.Warning: + return warning_yellow; + + case IssueType.Negligible: + return negligible_green; + + case IssueType.Error: + return error_gray; + + default: + return Color4.White; + } + } + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/Components/IssueType.cs b/osu.Game/Rulesets/Edit/Checks/Components/IssueType.cs new file mode 100644 index 0000000000..1f708209fe --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/Components/IssueType.cs @@ -0,0 +1,24 @@ +// 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.Rulesets.Edit.Checks.Components +{ + /// + /// The type, or severity, of an issue. + /// + public enum IssueType + { + /// A must-fix in the vast majority of cases. + Problem, + + /// A possible mistake. Often requires critical thinking. + Warning, + + // TODO: Try/catch all checks run and return error templates if exceptions occur. + /// An error occurred and a complete check could not be made. + Error, + + /// A possible mistake so minor/unlikely that it can often be safely ignored. + Negligible, + } +} diff --git a/osu.Game/Rulesets/Edit/DrawableEditRulesetWrapper.cs b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs similarity index 91% rename from osu.Game/Rulesets/Edit/DrawableEditRulesetWrapper.cs rename to osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs index c60d4c7834..8166e6b8ce 100644 --- a/osu.Game/Rulesets/Edit/DrawableEditRulesetWrapper.cs +++ b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Edit /// /// A wrapper for a . Handles adding visual representations of s to the underlying . /// - internal class DrawableEditRulesetWrapper : CompositeDrawable + internal class DrawableEditorRulesetWrapper : CompositeDrawable where TObject : HitObject { public Playfield Playfield => drawableRuleset.Playfield; @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Edit [Resolved] private EditorBeatmap beatmap { get; set; } - public DrawableEditRulesetWrapper(DrawableRuleset drawableRuleset) + public DrawableEditorRulesetWrapper(DrawableRuleset drawableRuleset) { this.drawableRuleset = drawableRuleset; @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Edit } } - private void updateReplay() => drawableRuleset.RegenerateAutoplay(); + private void updateReplay() => Scheduler.AddOnce(drawableRuleset.RegenerateAutoplay); private void addHitObject(HitObject hitObject) { diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index e927951d0a..b47cf97a4d 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Edit protected ComposeBlueprintContainer BlueprintContainer { get; private set; } - private DrawableEditRulesetWrapper drawableRulesetWrapper; + private DrawableEditorRulesetWrapper drawableRulesetWrapper; protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both }; @@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Edit try { - drawableRulesetWrapper = new DrawableEditRulesetWrapper(CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap, new[] { Ruleset.GetAutoplayMod() })) + drawableRulesetWrapper = new DrawableEditorRulesetWrapper(CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap, new[] { Ruleset.GetAutoplayMod() })) { Clock = EditorClock, ProcessCustomClock = false @@ -182,8 +182,7 @@ namespace osu.Game.Rulesets.Edit /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. /// - protected virtual ComposeBlueprintContainer CreateBlueprintContainer() - => new ComposeBlueprintContainer(this); + protected virtual ComposeBlueprintContainer CreateBlueprintContainer() => new ComposeBlueprintContainer(this); /// /// Construct a drawable ruleset for the provided ruleset. @@ -438,6 +437,8 @@ namespace osu.Game.Rulesets.Edit /// public abstract bool CursorInPlacementArea { get; } + public virtual string ConvertSelectionToString() => string.Empty; + #region IPositionSnapProvider public abstract SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); diff --git a/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs similarity index 66% rename from osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs rename to osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs index 75200e3027..56434b1d82 100644 --- a/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs @@ -3,17 +3,18 @@ using osu.Framework.Graphics.Primitives; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Edit { - public abstract class OverlaySelectionBlueprint : SelectionBlueprint + public abstract class HitObjectSelectionBlueprint : SelectionBlueprint { /// - /// The which this applies to. + /// The which this applies to. /// - public readonly DrawableHitObject DrawableObject; + public DrawableHitObject DrawableObject { get; internal set; } /// /// Whether the blueprint should be shown even when the is not alive. @@ -22,10 +23,9 @@ namespace osu.Game.Rulesets.Edit protected override bool ShouldBeAlive => (DrawableObject.IsAlive && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected); - protected OverlaySelectionBlueprint(DrawableHitObject drawableObject) - : base(drawableObject.HitObject) + protected HitObjectSelectionBlueprint(HitObject hitObject) + : base(hitObject) { - DrawableObject = drawableObject; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.ReceivePositionalInputAt(screenSpacePos); @@ -33,7 +33,16 @@ namespace osu.Game.Rulesets.Edit public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.ScreenSpaceDrawQuad.Centre; public override Quad SelectionQuad => DrawableObject.ScreenSpaceDrawQuad; + } - public override Vector2 GetInstantDelta(Vector2 screenSpacePosition) => DrawableObject.Parent.ToLocalSpace(screenSpacePosition) - DrawableObject.Position; + public abstract class HitObjectSelectionBlueprint : HitObjectSelectionBlueprint + where T : HitObject + { + public T HitObject => (T)Item; + + protected HitObjectSelectionBlueprint(T item) + : base(item) + { + } } } diff --git a/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs b/osu.Game/Rulesets/Edit/IBeatmapVerifier.cs new file mode 100644 index 0000000000..06f0abedb0 --- /dev/null +++ b/osu.Game/Rulesets/Edit/IBeatmapVerifier.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.Collections.Generic; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit +{ + /// + /// A class which can run against a beatmap and surface issues to the user which could go against known criteria or hinder gameplay. + /// + public interface IBeatmapVerifier + { + public IEnumerable Run(BeatmapVerifierContext context); + } +} diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index bfff93e7c5..82e90399c9 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Edit { @@ -25,7 +26,7 @@ namespace osu.Game.Rulesets.Edit /// /// Whether the is currently mid-placement, but has not necessarily finished being placed. /// - public bool PlacementActive { get; private set; } + public PlacementState PlacementActive { get; private set; } /// /// The that is being placed. @@ -35,7 +36,8 @@ namespace osu.Game.Rulesets.Edit [Resolved(canBeNull: true)] protected EditorClock EditorClock { get; private set; } - private readonly IBindable beatmap = new Bindable(); + [Resolved] + private EditorBeatmap beatmap { get; set; } private Bindable startTimeBindable; @@ -57,10 +59,8 @@ namespace osu.Game.Rulesets.Edit } [BackgroundDependencyLoader] - private void load(IBindable beatmap) + private void load() { - this.beatmap.BindTo(beatmap); - startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy(); startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true); } @@ -72,7 +72,8 @@ namespace osu.Game.Rulesets.Edit protected void BeginPlacement(bool commitStart = false) { placementHandler.BeginPlacement(HitObject); - PlacementActive |= commitStart; + if (commitStart) + PlacementActive = PlacementState.Active; } /// @@ -82,10 +83,19 @@ namespace osu.Game.Rulesets.Edit /// Whether the object should be committed. public void EndPlacement(bool commit) { - if (!PlacementActive) - BeginPlacement(); + switch (PlacementActive) + { + case PlacementState.Finished: + return; + + case PlacementState.Waiting: + // ensure placement was started before ending to make state handling simpler. + BeginPlacement(); + break; + } + placementHandler.EndPlacement(HitObject, commit); - PlacementActive = false; + PlacementActive = PlacementState.Finished; } /// @@ -94,7 +104,7 @@ namespace osu.Game.Rulesets.Edit /// The snap result information. public virtual void UpdateTimeAndPosition(SnapResult result) { - if (!PlacementActive) + if (PlacementActive == PlacementState.Waiting) HitObject.StartTime = result.Time ?? EditorClock?.CurrentTime ?? Time.Current; } @@ -102,7 +112,7 @@ namespace osu.Game.Rulesets.Edit /// Invokes , /// refreshing and parameters for the . /// - protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.Value.Beatmap.ControlPointInfo, beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty); + protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.ReceivePositionalInputAt(screenSpacePos) ?? false; @@ -118,12 +128,22 @@ namespace osu.Game.Rulesets.Edit case DoubleClickEvent _: return false; - case MouseButtonEvent _: - return true; + case MouseButtonEvent mouse: + // placement blueprints should generally block mouse from reaching underlying components (ie. performing clicks on interface buttons). + // for now, the one exception we want to allow is when using a non-main mouse button when shift is pressed, which is used to trigger object deletion + // while in placement mode. + return mouse.Button == MouseButton.Left || !mouse.ShiftPressed; default: return false; } } + + public enum PlacementState + { + Waiting, + Active, + Finished + } } } diff --git a/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs b/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs new file mode 100644 index 0000000000..a54f574bff --- /dev/null +++ b/osu.Game/Rulesets/Edit/ScrollingToolboxGroup.cs @@ -0,0 +1,32 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Rulesets.Edit +{ + public class ScrollingToolboxGroup : ToolboxGroup + { + protected readonly OsuScrollContainer Scroll; + + protected override Container Content { get; } + + public ScrollingToolboxGroup(string title, float scrollAreaHeight) + : base(title) + { + base.Content.Add(Scroll = new OsuScrollContainer + { + RelativeSizeAxes = Axes.X, + Height = scrollAreaHeight, + Child = Content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, + }); + } + } +} diff --git a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs index 99cdca045b..12ab89f79e 100644 --- a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs @@ -3,44 +3,38 @@ using System; using osu.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Edit { /// - /// A blueprint placed above a adding editing functionality. + /// A blueprint placed above a displaying item adding editing functionality. /// - public abstract class SelectionBlueprint : CompositeDrawable, IStateful + public abstract class SelectionBlueprint : CompositeDrawable, IStateful { - public readonly HitObject HitObject; + public readonly T Item; /// - /// Invoked when this has been selected. + /// Invoked when this has been selected. /// - public event Action Selected; + public event Action> Selected; /// - /// Invoked when this has been deselected. + /// Invoked when this has been deselected. /// - public event Action Deselected; + public event Action> Deselected; public override bool HandlePositionalInput => ShouldBeAlive; public override bool RemoveWhenNotAlive => false; - [Resolved(CanBeNull = true)] - private HitObjectComposer composer { get; set; } - - protected SelectionBlueprint(HitObject hitObject) + protected SelectionBlueprint(T item) { - HitObject = hitObject; + Item = item; RelativeSizeAxes = Axes.Both; AlwaysPresent = true; @@ -91,7 +85,7 @@ namespace osu.Game.Rulesets.Edit protected virtual void OnDeselected() { - // selection blueprints are AlwaysPresent while the related DrawableHitObject is visible + // selection blueprints are AlwaysPresent while the related item is visible // set the body piece's alpha directly to avoid arbitrarily rendering frame buffers etc. of children. foreach (var d in InternalChildren) d.Hide(); @@ -111,39 +105,37 @@ namespace osu.Game.Rulesets.Edit protected override bool ShouldBeConsideredForInput(Drawable child) => State == SelectionState.Selected; /// - /// Selects this , causing it to become visible. + /// Selects this , causing it to become visible. /// public void Select() => State = SelectionState.Selected; /// - /// Deselects this , causing it to become invisible. + /// Deselects this , causing it to become invisible. /// public void Deselect() => State = SelectionState.NotSelected; /// - /// Toggles the selection state of this . + /// Toggles the selection state of this . /// public void ToggleSelection() => State = IsSelected ? SelectionState.NotSelected : SelectionState.Selected; public bool IsSelected => State == SelectionState.Selected; /// - /// The s to be displayed in the context menu for this . + /// The s to be displayed in the context menu for this . /// public virtual MenuItem[] ContextMenuItems => Array.Empty(); /// - /// The screen-space point that causes this to be selected. + /// The screen-space point that causes this to be selected via a drag. /// public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre; /// - /// The screen-space quad that outlines this for selections. + /// The screen-space quad that outlines this for selections. /// public virtual Quad SelectionQuad => ScreenSpaceDrawQuad; - public virtual Vector2 GetInstantDelta(Vector2 screenSpacePosition) => Parent.ToLocalSpace(screenSpacePosition) - Position; - /// /// Handle to perform a partial deletion when the user requests a quick delete (Shift+Right Click). /// diff --git a/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.cs b/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.cs new file mode 100644 index 0000000000..13cc41f8e0 --- /dev/null +++ b/osu.Game/Rulesets/Filter/IRulesetFilterCriteria.cs @@ -0,0 +1,55 @@ +// 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.Screens.Select; +using osu.Game.Screens.Select.Filter; + +namespace osu.Game.Rulesets.Filter +{ + /// + /// Allows for extending the beatmap filtering capabilities of song select (as implemented in ) + /// with ruleset-specific criteria. + /// + public interface IRulesetFilterCriteria + { + /// + /// Checks whether the supplied satisfies ruleset-specific custom criteria, + /// in addition to the ones mandated by song select. + /// + /// The beatmap to test the criteria against. + /// + /// true if the beatmap matches the ruleset-specific custom filtering criteria, + /// false otherwise. + /// + bool Matches(BeatmapInfo beatmap); + + /// + /// Attempts to parse a single custom keyword criterion, given by the user via the song select search box. + /// The format of the criterion is: + /// + /// {key}{op}{value} + /// + /// + /// + /// + /// For adding optional string criteria, can be used for matching, + /// along with for parsing. + /// + /// + /// For adding numerical-type range criteria, can be used for matching, + /// along with + /// and - and -typed overloads for parsing. + /// + /// + /// The key (name) of the criterion. + /// The operator in the criterion. + /// The value of the criterion. + /// + /// true if the keyword criterion is valid, false if it has been ignored. + /// Valid criteria are stripped from , + /// while ignored criteria are included in . + /// + bool TryParseCustomKeywordCriteria(string key, Operator op, string value); + } +} diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index da9bb8a09d..feeafb7151 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -150,17 +150,13 @@ namespace osu.Game.Rulesets.Judgements } if (JudgementBody.Drawable is IAnimatableJudgement animatable) - { - var drawableAnimation = (Drawable)animatable; - animatable.PlayAnimation(); - // a derived version of DrawableJudgement may be proposing a lifetime. - // if not adjusted (or the skinned portion requires greater bounds than calculated) use the skinned source's lifetime. - double lastTransformTime = drawableAnimation.LatestTransformEndTime; - if (LifetimeEnd == double.MaxValue || lastTransformTime > LifetimeEnd) - LifetimeEnd = lastTransformTime; - } + // a derived version of DrawableJudgement may be proposing a lifetime. + // if not adjusted (or the skinned portion requires greater bounds than calculated) use the skinned source's lifetime. + double lastTransformTime = JudgementBody.Drawable.LatestTransformEndTime; + if (LifetimeEnd == double.MaxValue || lastTransformTime > LifetimeEnd) + LifetimeEnd = lastTransformTime; } } diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs index 89a3a2b855..be69db5ca8 100644 --- a/osu.Game/Rulesets/Judgements/Judgement.cs +++ b/osu.Game/Rulesets/Judgements/Judgement.cs @@ -28,18 +28,6 @@ namespace osu.Game.Rulesets.Judgements /// protected const double DEFAULT_MAX_HEALTH_INCREASE = 0.05; - /// - /// Whether this should affect the current combo. - /// - [Obsolete("Has no effect. Use HitResult members instead (e.g. use small-tick or bonus to not affect combo).")] // Can be removed 20210328 - public virtual bool AffectsCombo => true; - - /// - /// Whether this should be counted as base (combo) or bonus score. - /// - [Obsolete("Has no effect. Use HitResult members instead (e.g. use small-tick or bonus to not affect combo).")] // Can be removed 20210328 - public virtual bool IsBonus => !AffectsCombo; - /// /// The maximum that can be achieved. /// @@ -181,7 +169,7 @@ namespace osu.Game.Rulesets.Judgements return 300; case HitResult.Perfect: - return 350; + return 315; case HitResult.SmallBonus: return SMALL_BONUS_SCORE; diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 2a11c92223..7f48888abe 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -7,11 +7,13 @@ using System.Linq; using System.Reflection; using Newtonsoft.Json; using osu.Framework.Bindables; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics.Sprites; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.IO.Serialization; using osu.Game.Rulesets.UI; +using osu.Game.Utils; namespace osu.Game.Rulesets.Mods { @@ -19,7 +21,7 @@ namespace osu.Game.Rulesets.Mods /// The base class for gameplay modifiers. /// [ExcludeFromDynamicCompile] - public abstract class Mod : IMod, IJsonSerializable + public abstract class Mod : IMod, IEquatable, IJsonSerializable { /// /// The name of this mod. @@ -48,7 +50,7 @@ namespace osu.Game.Rulesets.Mods /// The user readable description of this mod. /// [JsonIgnore] - public virtual string Description => string.Empty; + public abstract string Description { get; } /// /// The tooltip to display for this mod when used in a . @@ -169,10 +171,27 @@ namespace osu.Game.Rulesets.Mods target.UnbindFrom(sourceBindable); } else - target.Parse(source); + { + if (!(target is IParseable parseable)) + throw new InvalidOperationException($"Bindable type {target.GetType().ReadableName()} is not {nameof(IParseable)}."); + + parseable.Parse(source); + } } - public bool Equals(IMod other) => GetType() == other?.GetType(); + public bool Equals(IMod other) => other is Mod them && Equals(them); + + public bool Equals(Mod other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return GetType() == other.GetType() && + this.GetSettingsSourceProperties().All(pair => + EqualityComparer.Default.Equals( + ModUtils.GetSettingUnderlyingValue(pair.Item2.GetValue(this)), + ModUtils.GetSettingUnderlyingValue(pair.Item2.GetValue(other)))); + } /// /// Reset all custom settings for this mod back to their defaults. diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index d1d23def67..d6e1d46b06 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -35,7 +35,7 @@ namespace osu.Game.Rulesets.Mods public bool RestartOnFail => false; - public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModNoFail) }; + public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail) }; public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0; diff --git a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs new file mode 100644 index 0000000000..0d344b5269 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs @@ -0,0 +1,57 @@ +// 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; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModBarrelRoll : Mod, IUpdatableByPlayfield, IApplicableToDrawableRuleset + where TObject : HitObject + { + /// + /// The current angle of rotation being applied by this mod. + /// Generally should be used to apply inverse rotation to elements which should not be rotated. + /// + protected float CurrentRotation { get; private set; } + + [SettingSource("Roll speed", "Rotations per minute")] + public BindableNumber SpinSpeed { get; } = new BindableDouble(0.5) + { + MinValue = 0.02, + MaxValue = 12, + Precision = 0.01, + }; + + [SettingSource("Direction", "The direction of rotation")] + public Bindable Direction { get; } = new Bindable(RotationDirection.Clockwise); + + public override string Name => "Barrel Roll"; + public override string Acronym => "BR"; + public override string Description => "The whole playfield is on a wheel!"; + public override double ScoreMultiplier => 1; + + public override string SettingDescription => $"{SpinSpeed.Value} rpm {Direction.Value.GetDescription().ToLowerInvariant()}"; + + public void Update(Playfield playfield) + { + playfield.Rotation = CurrentRotation = (Direction.Value == RotationDirection.Counterclockwise ? -1 : 1) * 360 * (float)(playfield.Time.Current / 60000 * SpinSpeed.Value); + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + // scale the playfield to allow all hitobjects to stay within the visible region. + + var playfieldSize = drawableRuleset.Playfield.DrawSize; + var minSide = MathF.Min(playfieldSize.X, playfieldSize.Y); + var maxSide = MathF.Max(playfieldSize.X, playfieldSize.Y); + drawableRuleset.Playfield.Scale = new Vector2(minSide / maxSide); + } + } +} diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index eb0473016a..c78088ba2d 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -37,8 +37,7 @@ namespace osu.Game.Rulesets.Mods public void ApplyToPlayer(Player player) { - player.ApplyToBackground(b => b.EnableUserDim.Value = false); - + player.ApplyToBackground(b => b.IgnoreUserSettings.Value = true); player.DimmableStoryboard.IgnoreUserSettings.Value = true; player.BreakOverlay.Hide(); diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs new file mode 100644 index 0000000000..f1207ec188 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -0,0 +1,24 @@ +// 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.Graphics.Sprites; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModClassic : Mod + { + public override string Name => "Classic"; + + public override string Acronym => "CL"; + + public override double ScoreMultiplier => 1; + + public override IconUsage? Icon => FontAwesome.Solid.History; + + public override string Description => "Feeling nostalgic?"; + + public override bool Ranked => false; + + public override ModType Type => ModType.Conversion; + } +} diff --git a/osu.Game/Rulesets/Mods/ModFailCondition.cs b/osu.Game/Rulesets/Mods/ModFailCondition.cs new file mode 100644 index 0000000000..c0d7bae2b2 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModFailCondition.cs @@ -0,0 +1,25 @@ +// 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.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModFailCondition : Mod, IApplicableToHealthProcessor, IApplicableFailOverride + { + public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModRelax), typeof(ModAutoplay) }; + + public virtual bool PerformFail() => true; + + public virtual bool RestartOnFail => true; + + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) + { + healthProcessor.FailConditions += FailCondition; + } + + protected abstract bool FailCondition(HealthProcessor healthProcessor, JudgementResult result); + } +} diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index df421adbe5..238612b3d2 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -1,9 +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.Game.Graphics; -using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Graphics.Sprites; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -18,14 +16,6 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyIncrease; public override bool Ranked => true; - /// - /// Check whether the provided hitobject should be considered the "first" hideable object. - /// Can be used to skip spinners, for instance. - /// - /// The hitobject to check. - [Obsolete("Use IsFirstAdjustableObject() instead.")] // Can be removed 20210506 - protected virtual bool IsFirstHideableObject(DrawableHitObject hitObject) => true; - public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { // Default value of ScoreProcessor's Rank in Hidden Mod should be SS+ @@ -46,39 +36,5 @@ namespace osu.Game.Rulesets.Mods return rank; } } - - protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) - { -#pragma warning disable 618 - ApplyFirstObjectIncreaseVisibilityState(hitObject, state); -#pragma warning restore 618 - } - - protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) - { -#pragma warning disable 618 - ApplyHiddenState(hitObject, state); -#pragma warning restore 618 - } - - /// - /// Apply a special visibility state to the first object in a beatmap, if the user chooses to turn on the "increase first object visibility" setting. - /// - /// The hit object to apply the state change to. - /// The state of the hit object. - [Obsolete("Use ApplyIncreasedVisibilityState() instead.")] // Can be removed 20210506 - protected virtual void ApplyFirstObjectIncreaseVisibilityState(DrawableHitObject hitObject, ArmedState state) - { - } - - /// - /// Apply a hidden state to the provided object. - /// - /// The hit object to apply the state change to. - /// The state of the hit object. - [Obsolete("Use ApplyNormalVisibilityState() instead.")] // Can be removed 20210506 - protected virtual void ApplyHiddenState(DrawableHitObject hitObject, ArmedState state) - { - } } } diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index b95ec7490e..c0f24e116a 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -16,6 +16,6 @@ namespace osu.Game.Rulesets.Mods public override string Description => "You can't fail, no matter what."; public override double ScoreMultiplier => 0.5; public override bool Ranked => true; - public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModAutoplay) }; + public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModAutoplay) }; } } diff --git a/osu.Game/Rulesets/Mods/ModNoMod.cs b/osu.Game/Rulesets/Mods/ModNoMod.cs index 379a2122f2..1009c5bc42 100644 --- a/osu.Game/Rulesets/Mods/ModNoMod.cs +++ b/osu.Game/Rulesets/Mods/ModNoMod.cs @@ -12,6 +12,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "No Mod"; public override string Acronym => "NM"; + public override string Description => "No mods applied."; public override double ScoreMultiplier => 1; public override IconUsage? Icon => FontAwesome.Solid.Ban; public override ModType Type => ModType.System; diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index df0fc9c4b6..d0b09b50f2 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.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; +using System.Linq; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; @@ -8,13 +10,18 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModPerfect : ModSuddenDeath + public abstract class ModPerfect : ModFailCondition { public override string Name => "Perfect"; public override string Acronym => "PF"; public override IconUsage? Icon => OsuIcon.ModPerfect; + public override ModType Type => ModType.DifficultyIncrease; + public override bool Ranked => true; + public override double ScoreMultiplier => 1; public override string Description => "SS or quit."; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModSuddenDeath)).ToArray(); + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) => result.Type.AffectsAccuracy() && result.Type != result.Judgement.MaxResult; diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index b016a6d43b..e66650f7b4 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.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.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; @@ -24,6 +25,8 @@ namespace osu.Game.Rulesets.Mods public double ApplyToRate(double time, double rate) => rate * SpeedChange.Value; + public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp) }; + public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x"; } } diff --git a/osu.Game/Rulesets/Mods/ModRelax.cs b/osu.Game/Rulesets/Mods/ModRelax.cs index b6fec42f43..e5995ff180 100644 --- a/osu.Game/Rulesets/Mods/ModRelax.cs +++ b/osu.Game/Rulesets/Mods/ModRelax.cs @@ -14,6 +14,6 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => OsuIcon.ModRelax; public override ModType Type => ModType.Automation; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModSuddenDeath) }; + public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) }; } } diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index ae71041a64..617ae38feb 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; @@ -9,7 +10,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModSuddenDeath : Mod, IApplicableToHealthProcessor, IApplicableFailOverride + public abstract class ModSuddenDeath : ModFailCondition { public override string Name => "Sudden Death"; public override string Acronym => "SD"; @@ -18,18 +19,10 @@ namespace osu.Game.Rulesets.Mods public override string Description => "Miss and fail."; public override double ScoreMultiplier => 1; public override bool Ranked => true; - public override Type[] IncompatibleMods => new[] { typeof(ModNoFail), typeof(ModRelax), typeof(ModAutoplay) }; - public bool PerformFail() => true; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray(); - public bool RestartOnFail => true; - - public void ApplyToHealthProcessor(HealthProcessor healthProcessor) - { - healthProcessor.FailConditions += FailCondition; - } - - protected virtual bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) => result.Type.AffectsCombo() && !result.IsHit; } diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index 330945d3d3..b5cd64dafa 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] public abstract BindableBool AdjustPitch { get; } + public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust) }; + public override string SettingDescription => $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x"; private double finalRateTime; diff --git a/osu.Game/Rulesets/Mods/ModWindDown.cs b/osu.Game/Rulesets/Mods/ModWindDown.cs index 679b50057b..08bd44f7bd 100644 --- a/osu.Game/Rulesets/Mods/ModWindDown.cs +++ b/osu.Game/Rulesets/Mods/ModWindDown.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Initial rate", "The starting speed of the track")] public override BindableNumber InitialRate { get; } = new BindableDouble { - MinValue = 1, + MinValue = 0.51, MaxValue = 2, Default = 1, Value = 1, @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Mods public override BindableNumber FinalRate { get; } = new BindableDouble { MinValue = 0.5, - MaxValue = 0.99, + MaxValue = 1.99, Default = 0.75, Value = 0.75, Precision = 0.01, @@ -45,5 +45,20 @@ namespace osu.Game.Rulesets.Mods }; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindUp)).ToArray(); + + public ModWindDown() + { + InitialRate.BindValueChanged(val => + { + if (val.NewValue <= FinalRate.Value) + FinalRate.Value = val.NewValue - FinalRate.Precision; + }); + + FinalRate.BindValueChanged(val => + { + if (val.NewValue >= InitialRate.Value) + InitialRate.Value = val.NewValue + InitialRate.Precision; + }); + } } } diff --git a/osu.Game/Rulesets/Mods/ModWindUp.cs b/osu.Game/Rulesets/Mods/ModWindUp.cs index b733bf423e..df8f781148 100644 --- a/osu.Game/Rulesets/Mods/ModWindUp.cs +++ b/osu.Game/Rulesets/Mods/ModWindUp.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mods public override BindableNumber InitialRate { get; } = new BindableDouble { MinValue = 0.5, - MaxValue = 1, + MaxValue = 1.99, Default = 1, Value = 1, Precision = 0.01, @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Final rate", "The speed increase to ramp towards")] public override BindableNumber FinalRate { get; } = new BindableDouble { - MinValue = 1.01, + MinValue = 0.51, MaxValue = 2, Default = 1.5, Value = 1.5, @@ -45,5 +45,20 @@ namespace osu.Game.Rulesets.Mods }; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindDown)).ToArray(); + + public ModWindUp() + { + InitialRate.BindValueChanged(val => + { + if (val.NewValue >= FinalRate.Value) + FinalRate.Value = val.NewValue + FinalRate.Precision; + }); + + FinalRate.BindValueChanged(val => + { + if (val.NewValue <= InitialRate.Value) + InitialRate.Value = val.NewValue - InitialRate.Precision; + }); + } } } diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index e5eaf5db88..6c688c1625 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -11,21 +12,21 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; -using osu.Framework.Logging; using osu.Framework.Threading; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Pooling; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; -using osu.Game.Configuration; using osu.Game.Rulesets.UI; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Drawables { [Cached(typeof(DrawableHitObject))] - public abstract class DrawableHitObject : SkinReloadableDrawable + public abstract class DrawableHitObject : PoolableDrawableWithLifetime { /// /// Invoked after this 's applied has had its defaults applied. @@ -40,7 +41,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// The currently represented by this . /// - public HitObject HitObject { get; private set; } + public HitObject HitObject => Entry?.HitObject; /// /// The parenting , if any. @@ -57,8 +58,8 @@ namespace osu.Game.Rulesets.Objects.Drawables public virtual IEnumerable GetSamples() => HitObject.Samples; - private readonly Lazy> nestedHitObjects = new Lazy>(); - public IReadOnlyList NestedHitObjects => nestedHitObjects.IsValueCreated ? nestedHitObjects.Value : (IReadOnlyList)Array.Empty(); + private readonly List nestedHitObjects = new List(); + public IReadOnlyList NestedHitObjects => nestedHitObjects; /// /// Whether this object should handle any user input events. @@ -109,7 +110,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// The scoring result of this . /// - public JudgementResult Result { get; private set; } + public JudgementResult Result => Entry?.Result; /// /// The relative X position of this hit object for sample playback balance adjustment. @@ -125,8 +126,6 @@ namespace osu.Game.Rulesets.Objects.Drawables private readonly Bindable userPositionalHitSounds = new Bindable(); private readonly Bindable comboIndexBindable = new Bindable(); - public override bool RemoveWhenNotAlive => false; - public override bool RemoveCompletedTransforms => false; protected override bool RequiresChildrenUpdate => true; public override bool IsPresent => base.IsPresent || (State.Value == ArmedState.Idle && Clock?.CurrentTime >= LifetimeStart); @@ -141,17 +140,6 @@ namespace osu.Game.Rulesets.Objects.Drawables /// public IBindable State => state; - /// - /// Whether is currently applied. - /// - private bool hasHitObjectApplied; - - /// - /// The controlling the lifetime of the currently-attached . - /// - [CanBeNull] - private HitObjectLifetimeEntry lifetimeEntry; - [Resolved(CanBeNull = true)] private IPooledHitObjectProvider pooledObjectProvider { get; set; } @@ -165,28 +153,31 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// /// The to be initially applied to this . - /// If null, a hitobject is expected to be later applied via (or automatically via pooling). + /// If null, a hitobject is expected to be later applied via (or automatically via pooling). /// protected DrawableHitObject([CanBeNull] HitObject initialHitObject = null) + : base(initialHitObject != null ? new SyntheticHitObjectEntry(initialHitObject) : null) { - HitObject = initialHitObject; + if (Entry != null) + ensureEntryHasResult(); } [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(OsuConfigManager config, ISkinSource skinSource) { config.BindWith(OsuSetting.PositionalHitSounds, userPositionalHitSounds); // Explicit non-virtual function call. base.AddInternal(Samples = new PausableSkinnableSound()); + + CurrentSkin = skinSource; + CurrentSkin.SourceChanged += skinSourceChanged; } protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); - - if (HitObject != null) - Apply(HitObject, lifetimeEntry); + skinChanged(); } protected override void LoadComplete() @@ -199,37 +190,37 @@ namespace osu.Game.Rulesets.Objects.Drawables } /// - /// Applies a new to be represented by this . + /// Applies a hit object to be represented by this . /// - /// The to apply. - /// The controlling the lifetime of . + [Obsolete("Use either overload of Apply that takes a single argument of type HitObject or HitObjectLifetimeEntry")] public void Apply([NotNull] HitObject hitObject, [CanBeNull] HitObjectLifetimeEntry lifetimeEntry) { - free(); - - HitObject = hitObject ?? throw new InvalidOperationException($"Cannot apply a null {nameof(HitObject)}."); - - this.lifetimeEntry = lifetimeEntry; - if (lifetimeEntry != null) - { - // Transfer lifetime from the entry. - LifetimeStart = lifetimeEntry.LifetimeStart; - LifetimeEnd = lifetimeEntry.LifetimeEnd; - - // Copy any existing result from the entry (required for rewind / judgement revert). - Result = lifetimeEntry.Result; - } + Apply(lifetimeEntry); else + Apply(hitObject); + } + + /// + /// Applies a new to be represented by this . + /// A new is automatically created and applied to this . + /// + public void Apply([NotNull] HitObject hitObject) + { + if (hitObject == null) + throw new ArgumentNullException($"Cannot apply a null {nameof(HitObject)}."); + + Apply(new SyntheticHitObjectEntry(hitObject)); + } + + protected sealed override void OnApply(HitObjectLifetimeEntry entry) + { + // LifetimeStart is already computed using HitObjectLifetimeEntry's InitialLifetimeOffset. + // We override this with DHO's InitialLifetimeOffset for a non-pooled DHO. + if (entry is SyntheticHitObjectEntry) LifetimeStart = HitObject.StartTime - InitialLifetimeOffset; - // Ensure this DHO has a result. - Result ??= CreateResult(HitObject.CreateJudgement()) - ?? throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); - - // Copy back the result to the entry for potential future retrieval. - if (lifetimeEntry != null) - lifetimeEntry.Result = Result; + ensureEntryHasResult(); foreach (var h in HitObject.NestedHitObjects) { @@ -250,7 +241,7 @@ namespace osu.Game.Rulesets.Objects.Drawables // Must be done before the nested DHO is added to occur before the nested Apply()! drawableNested.ParentHitObject = this; - nestedHitObjects.Value.Add(drawableNested); + nestedHitObjects.Add(drawableNested); AddNestedHitObject(drawableNested); } @@ -278,18 +269,10 @@ namespace osu.Game.Rulesets.Objects.Drawables else updateState(ArmedState.Idle, true); } - - hasHitObjectApplied = true; } - /// - /// Removes the currently applied - /// - private void free() + protected sealed override void OnFree(HitObjectLifetimeEntry entry) { - if (!hasHitObjectApplied) - return; - StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable); if (HitObject is IHasComboInformation combo) comboIndexBindable.UnbindFrom(combo.ComboIndexBindable); @@ -306,46 +289,28 @@ namespace osu.Game.Rulesets.Objects.Drawables if (Samples != null) Samples.Samples = null; - if (nestedHitObjects.IsValueCreated) + foreach (var obj in nestedHitObjects) { - foreach (var obj in nestedHitObjects.Value) - { - obj.OnNewResult -= onNewResult; - obj.OnRevertResult -= onRevertResult; - obj.ApplyCustomUpdateState -= onApplyCustomUpdateState; - } - - nestedHitObjects.Value.Clear(); - ClearNestedHitObjects(); + obj.OnNewResult -= onNewResult; + obj.OnRevertResult -= onRevertResult; + obj.ApplyCustomUpdateState -= onApplyCustomUpdateState; } + nestedHitObjects.Clear(); + ClearNestedHitObjects(); + HitObject.DefaultsApplied -= onDefaultsApplied; OnFree(); - HitObject = null; ParentHitObject = null; - Result = null; - lifetimeEntry = null; clearExistingStateTransforms(); - - hasHitObjectApplied = false; - } - - protected sealed override void FreeAfterUse() - { - base.FreeAfterUse(); - - // Freeing while not in a pool would cause the DHO to not be usable elsewhere in the hierarchy without being re-applied. - if (!IsInPool) - return; - - free(); } /// /// Invoked for this to take on any values from a newly-applied . + /// This is also fired after any changes which occurred via an call. /// protected virtual void OnApply() { @@ -353,6 +318,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// Invoked for this to revert any values previously taken on from the currently-applied . + /// This is also fired after any changes which occurred via an call. /// protected virtual void OnFree() { @@ -389,7 +355,9 @@ namespace osu.Game.Rulesets.Objects.Drawables private void onDefaultsApplied(HitObject hitObject) { - Apply(hitObject, lifetimeEntry); + Debug.Assert(Entry != null); + Apply(Entry); + DefaultsApplied?.Invoke(this); } @@ -469,11 +437,14 @@ namespace osu.Game.Rulesets.Objects.Drawables base.ClearTransformsAfter(double.MinValue, true); } + /// + /// Reapplies the current . + /// + protected void RefreshStateTransforms() => updateState(State.Value, true); + /// /// Apply (generally fade-in) transforms leading into the start time. - /// The local drawable hierarchy is recursively delayed to for convenience. - /// - /// By default this will fade in the object from zero with no duration. + /// By default, this will fade in the object from zero with no duration. /// /// /// This is called once before every . This is to ensure a good state in the case @@ -525,13 +496,17 @@ namespace osu.Game.Rulesets.Objects.Drawables #endregion - protected sealed override void SkinChanged(ISkinSource skin, bool allowFallback) - { - base.SkinChanged(skin, allowFallback); + #region Skinning + protected ISkinSource CurrentSkin { get; private set; } + + private void skinSourceChanged() => Scheduler.AddOnce(skinChanged); + + private void skinChanged() + { UpdateComboColour(); - ApplySkin(skin, allowFallback); + ApplySkin(CurrentSkin, true); if (IsLoaded) updateState(State.Value, true); @@ -575,7 +550,6 @@ namespace osu.Game.Rulesets.Objects.Drawables /// Calculate the position to be used for sample playback at a specified X position (0..1). /// /// The lookup X position. Generally should be . - /// protected double CalculateSamplePlaybackBalance(double position) { const float balance_adjust_amount = 0.4f; @@ -606,6 +580,8 @@ namespace osu.Game.Rulesets.Objects.Drawables Samples.Stop(); } + #endregion + protected override void Update() { base.Update(); @@ -643,42 +619,12 @@ namespace osu.Game.Rulesets.Objects.Drawables /// protected internal new ScheduledDelegate Schedule(Action action) => base.Schedule(action); - public override double LifetimeStart - { - get => base.LifetimeStart; - set => setLifetime(value, LifetimeEnd); - } - - public override double LifetimeEnd - { - get => base.LifetimeEnd; - set => setLifetime(LifetimeStart, value); - } - - private void setLifetime(double lifetimeStart, double lifetimeEnd) - { - base.LifetimeStart = lifetimeStart; - base.LifetimeEnd = lifetimeEnd; - - if (lifetimeEntry != null) - { - lifetimeEntry.LifetimeStart = lifetimeStart; - lifetimeEntry.LifetimeEnd = lifetimeEnd; - } - } - /// - /// A safe offset prior to the start time of at which this may begin displaying contents. + /// An offset prior to the start time of at which this may begin displaying contents. /// By default, s are assumed to display their contents within 10 seconds prior to the start time of . /// /// - /// This is only used as an optimisation to delay the initial update of this and may be tuned more aggressively if required. - /// It is indirectly used to decide the automatic transform offset provided to . - /// A more accurate should be set for further optimisation (in , for example). - /// - /// Only has an effect if this is not being pooled. - /// For pooled s, use instead. - /// + /// The initial transformation () starts at this offset before the start time of . /// protected virtual double InitialLifetimeOffset => 10000; @@ -736,24 +682,6 @@ namespace osu.Game.Rulesets.Objects.Drawables if (!Result.HasResult) throw new InvalidOperationException($"{GetType().ReadableName()} applied a {nameof(JudgementResult)} but did not update {nameof(JudgementResult.Type)}."); - // Some (especially older) rulesets use scorable judgements instead of the newer ignorehit/ignoremiss judgements. - // Can be removed 20210328 - if (Result.Judgement.MaxResult == HitResult.IgnoreHit) - { - HitResult originalType = Result.Type; - - if (Result.Type == HitResult.Miss) - Result.Type = HitResult.IgnoreMiss; - else if (Result.Type >= HitResult.Meh && Result.Type <= HitResult.Perfect) - Result.Type = HitResult.IgnoreHit; - - if (Result.Type != originalType) - { - Logger.Log($"{GetType().ReadableName()} applied an invalid hit result ({originalType}) when {nameof(HitResult.IgnoreMiss)} or {nameof(HitResult.IgnoreHit)} is expected.\n" - + $"This has been automatically adjusted to {Result.Type}, and support will be removed from 2021-03-28 onwards.", level: LogLevel.Important); - } - } - if (!Result.Type.IsValidHitResult(Result.Judgement.MinResult, Result.Judgement.MaxResult)) { throw new InvalidOperationException( @@ -806,12 +734,21 @@ namespace osu.Game.Rulesets.Objects.Drawables /// The that provides the scoring information. protected virtual JudgementResult CreateResult(Judgement judgement) => new JudgementResult(HitObject, judgement); + private void ensureEntryHasResult() + { + Debug.Assert(Entry != null); + Entry.Result ??= CreateResult(HitObject.CreateJudgement()) + ?? throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (HitObject != null) HitObject.DefaultsApplied -= onDefaultsApplied; + + CurrentSkin.SourceChanged -= skinSourceChanged; } } diff --git a/osu.Game/Rulesets/Objects/Drawables/IScrollingHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/IScrollingHitObject.cs deleted file mode 100644 index 48fcfabc2f..0000000000 --- a/osu.Game/Rulesets/Objects/Drawables/IScrollingHitObject.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.Framework.Graphics; - -namespace osu.Game.Rulesets.Objects.Drawables -{ - /// - /// An interface that exposes properties required for scrolling hit objects to be properly displayed. - /// - internal interface IScrollingHitObject : IDrawable - { - /// - /// Time offset before the hit object start time at which this becomes visible and the time offset - /// after the hit object's end time after which it expires. - /// - /// - /// This provides only a default life time range, however classes inheriting from should override - /// their life times if more tight control is desired. - /// - /// - BindableDouble LifetimeOffset { get; } - - /// - /// Axes which this will scroll through. - /// This is set by the container which this scrolls through. - /// - Axes ScrollingAxes { set; } - } -} diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index 826d411822..db02eafa92 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -139,15 +139,6 @@ namespace osu.Game.Rulesets.Objects } protected virtual void CreateNestedHitObjects(CancellationToken cancellationToken) - { - // ReSharper disable once MethodSupportsCancellation (https://youtrack.jetbrains.com/issue/RIDER-44520) -#pragma warning disable 618 - CreateNestedHitObjects(); -#pragma warning restore 618 - } - - [Obsolete("Use the cancellation-supporting override")] // Can be removed 20210318 - protected virtual void CreateNestedHitObjects() { } diff --git a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs index 1954d7e6d2..096dad88bd 100644 --- a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs +++ b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs @@ -35,43 +35,30 @@ namespace osu.Game.Rulesets.Objects HitObject = hitObject; startTimeBindable.BindTo(HitObject.StartTimeBindable); - startTimeBindable.BindValueChanged(onStartTimeChanged, true); + startTimeBindable.BindValueChanged(_ => SetInitialLifetime(), true); + + // Subscribe to this event before the DrawableHitObject so that the local callback is invoked before the entry is re-applied as a result of DefaultsApplied. + // This way, the DrawableHitObject can use OnApply() to overwrite the LifetimeStart that was set inside setInitialLifetime(). + HitObject.DefaultsApplied += _ => SetInitialLifetime(); } - // The lifetime start, as set by the hitobject. + // The lifetime, as set by the hitobject. private double realLifetimeStart = double.MinValue; - - /// - /// The time at which the should become alive. - /// - public new double LifetimeStart - { - get => realLifetimeStart; - set => setLifetime(realLifetimeStart = value, LifetimeEnd); - } - - // The lifetime end, as set by the hitobject. private double realLifetimeEnd = double.MaxValue; - /// - /// The time at which the should become dead. - /// - public new double LifetimeEnd + // This method is called even if `start == LifetimeStart` when `KeepAlive` is true (necessary to update `realLifetimeStart`). + protected override void SetLifetimeStart(double start) { - get => realLifetimeEnd; - set => setLifetime(LifetimeStart, realLifetimeEnd = value); + realLifetimeStart = start; + if (!keepAlive) + base.SetLifetimeStart(start); } - private void setLifetime(double start, double end) + protected override void SetLifetimeEnd(double end) { - if (keepAlive) - { - start = double.MinValue; - end = double.MaxValue; - } - - base.LifetimeStart = start; - base.LifetimeEnd = end; + realLifetimeEnd = end; + if (!keepAlive) + base.SetLifetimeEnd(end); } private bool keepAlive; @@ -87,7 +74,10 @@ namespace osu.Game.Rulesets.Objects return; keepAlive = value; - setLifetime(realLifetimeStart, realLifetimeEnd); + if (keepAlive) + SetLifetime(double.MinValue, double.MaxValue); + else + SetLifetime(realLifetimeStart, realLifetimeEnd); } } @@ -96,15 +86,14 @@ namespace osu.Game.Rulesets.Objects /// By default, s are assumed to display their contents within 10 seconds prior to their start time. /// /// - /// This is only used as an optimisation to delay the initial update of the and may be tuned more aggressively if required. - /// It is indirectly used to decide the automatic transform offset provided to . - /// A more accurate should be set for further optimisation (in , for example). + /// This is only used as an optimisation to delay the initial application of the to a . + /// A more accurate should be set on the hit object application, for further optimisation. /// protected virtual double InitialLifetimeOffset => 10000; /// - /// Resets according to the change in start time of the . + /// Set using . /// - private void onStartTimeChanged(ValueChangedEvent startTime) => LifetimeStart = HitObject.StartTime - InitialLifetimeOffset; + internal void SetInitialLifetime() => LifetimeStart = HitObject.StartTime - InitialLifetimeOffset; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 72025de131..e8a5463cce 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -10,6 +10,7 @@ using osu.Game.Beatmaps.Formats; using osu.Game.Audio; using System.Linq; using JetBrains.Annotations; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Utils; using osu.Game.Beatmaps.Legacy; using osu.Game.Skinning; @@ -54,7 +55,7 @@ namespace osu.Game.Rulesets.Objects.Legacy int comboOffset = (int)(type & LegacyHitObjectType.ComboOffset) >> 4; type &= ~LegacyHitObjectType.ComboOffset; - bool combo = type.HasFlag(LegacyHitObjectType.NewCombo); + bool combo = type.HasFlagFast(LegacyHitObjectType.NewCombo); type &= ~LegacyHitObjectType.NewCombo; var soundType = (LegacyHitSoundType)Parsing.ParseInt(split[4]); @@ -62,14 +63,14 @@ namespace osu.Game.Rulesets.Objects.Legacy HitObject result = null; - if (type.HasFlag(LegacyHitObjectType.Circle)) + if (type.HasFlagFast(LegacyHitObjectType.Circle)) { result = CreateHit(pos, combo, comboOffset); if (split.Length > 5) readCustomSampleBanks(split[5], bankInfo); } - else if (type.HasFlag(LegacyHitObjectType.Slider)) + else if (type.HasFlagFast(LegacyHitObjectType.Slider)) { double? length = null; @@ -141,7 +142,7 @@ namespace osu.Game.Rulesets.Objects.Legacy result = CreateSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples); } - else if (type.HasFlag(LegacyHitObjectType.Spinner)) + else if (type.HasFlagFast(LegacyHitObjectType.Spinner)) { double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + Offset - startTime); @@ -150,7 +151,7 @@ namespace osu.Game.Rulesets.Objects.Legacy if (split.Length > 6) readCustomSampleBanks(split[6], bankInfo); } - else if (type.HasFlag(LegacyHitObjectType.Hold)) + else if (type.HasFlagFast(LegacyHitObjectType.Hold)) { // Note: Hold is generated by BMS converts @@ -335,9 +336,14 @@ namespace osu.Game.Rulesets.Objects.Legacy while (++endIndex < vertices.Length - endPointLength) { + // Keep incrementing while an implicit segment doesn't need to be started if (vertices[endIndex].Position.Value != vertices[endIndex - 1].Position.Value) continue; + // The last control point of each segment is not allowed to start a new implicit segment. + if (endIndex == vertices.Length - endPointLength - 1) + continue; + // Force a type on the last point, and return the current control point set as a segment. vertices[endIndex - 1].Type.Value = type; yield return vertices.AsMemory().Slice(startIndex, endIndex - startIndex); @@ -436,16 +442,16 @@ namespace osu.Game.Rulesets.Objects.Legacy new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.Normal, bankInfo.Volume, bankInfo.CustomSampleBank, // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds - type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal)) + type != LegacyHitSoundType.None && !type.HasFlagFast(LegacyHitSoundType.Normal)) }; - if (type.HasFlag(LegacyHitSoundType.Finish)) + if (type.HasFlagFast(LegacyHitSoundType.Finish)) soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_FINISH, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); - if (type.HasFlag(LegacyHitSoundType.Whistle)) + if (type.HasFlagFast(LegacyHitSoundType.Whistle)) soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_WHISTLE, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); - if (type.HasFlag(LegacyHitSoundType.Clap)) + if (type.HasFlagFast(LegacyHitSoundType.Clap)) soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_CLAP, bankInfo.Add, bankInfo.Volume, bankInfo.CustomSampleBank)); return soundTypes; diff --git a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs new file mode 100644 index 0000000000..4440ca8d21 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs @@ -0,0 +1,135 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Diagnostics; +using osu.Framework.Graphics.Performance; +using osu.Framework.Graphics.Pooling; + +namespace osu.Game.Rulesets.Objects.Pooling +{ + /// + /// A that is controlled by to implement drawable pooling and replay rewinding. + /// + /// The type storing state and controlling this drawable. + public abstract class PoolableDrawableWithLifetime : PoolableDrawable where TEntry : LifetimeEntry + { + /// + /// The entry holding essential state of this . + /// + public TEntry? Entry { get; private set; } + + /// + /// Whether is applied to this . + /// When an initial entry is specified in the constructor, is set but not applied until loading is completed. + /// + protected bool HasEntryApplied { get; private set; } + + public override double LifetimeStart + { + get => base.LifetimeStart; + set + { + if (Entry == null && LifetimeStart != value) + throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime)} when entry is not set"); + + if (Entry != null) + Entry.LifetimeStart = value; + } + } + + public override double LifetimeEnd + { + get => base.LifetimeEnd; + set + { + if (Entry == null && LifetimeEnd != value) + throw new InvalidOperationException($"Cannot modify lifetime of {nameof(PoolableDrawableWithLifetime)} when entry is not set"); + + if (Entry != null) + Entry.LifetimeEnd = value; + } + } + + public override bool RemoveWhenNotAlive => false; + public override bool RemoveCompletedTransforms => false; + + protected PoolableDrawableWithLifetime(TEntry? initialEntry = null) + { + Entry = initialEntry; + } + + protected override void LoadAsyncComplete() + { + base.LoadAsyncComplete(); + + // Apply the initial entry given in the constructor. + if (Entry != null && !HasEntryApplied) + Apply(Entry); + } + + /// + /// Applies a new entry to be represented by this drawable. + /// If there is an existing entry applied, the entry will be replaced. + /// + public void Apply(TEntry entry) + { + if (HasEntryApplied) + free(); + + Entry = entry; + entry.LifetimeChanged += setLifetimeFromEntry; + setLifetimeFromEntry(entry); + + OnApply(entry); + + HasEntryApplied = true; + } + + protected sealed override void FreeAfterUse() + { + base.FreeAfterUse(); + + // We preserve the existing entry in case we want to move a non-pooled drawable between different parent drawables. + if (HasEntryApplied && IsInPool) + free(); + } + + /// + /// Invoked to apply a new entry to this drawable. + /// + protected virtual void OnApply(TEntry entry) + { + } + + /// + /// Invoked to revert application of the entry to this drawable. + /// + protected virtual void OnFree(TEntry entry) + { + } + + private void free() + { + Debug.Assert(Entry != null && HasEntryApplied); + + OnFree(Entry); + + Entry.LifetimeChanged -= setLifetimeFromEntry; + Entry = null; + base.LifetimeStart = double.MinValue; + base.LifetimeEnd = double.MaxValue; + + HasEntryApplied = false; + } + + private void setLifetimeFromEntry(LifetimeEntry entry) + { + Debug.Assert(entry == Entry); + base.LifetimeStart = entry.LifetimeStart; + base.LifetimeEnd = entry.LifetimeEnd; + } + } +} diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index 3083fcfccb..55ef0bc5f6 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Objects /// public readonly Bindable ExpectedDistance = new Bindable(); + public bool HasValidLength => Distance > 0; + /// /// The control points of the path. /// @@ -147,7 +149,6 @@ namespace osu.Game.Rulesets.Objects /// to 1 (end of the path). /// /// Ranges from 0 (beginning of the path) to 1 (end of the path). - /// public Vector2 PositionAt(double progress) { ensureValid(); @@ -156,6 +157,38 @@ namespace osu.Game.Rulesets.Objects return interpolateVertices(indexOfDistance(d), d); } + /// + /// Returns the control points belonging to the same segment as the one given. + /// The first point has a PathType which all other points inherit. + /// + /// One of the control points in the segment. + public List PointsInSegment(PathControlPoint controlPoint) + { + bool found = false; + List pointsInCurrentSegment = new List(); + + foreach (PathControlPoint point in ControlPoints) + { + if (point.Type.Value != null) + { + if (!found) + pointsInCurrentSegment.Clear(); + else + { + pointsInCurrentSegment.Add(point); + break; + } + } + + pointsInCurrentSegment.Add(point); + + if (point == controlPoint) + found = true; + } + + return pointsInCurrentSegment; + } + private void invalidate() { pathCache.Invalidate(); diff --git a/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.cs b/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.cs new file mode 100644 index 0000000000..76f9eaf25a --- /dev/null +++ b/osu.Game/Rulesets/Objects/SyntheticHitObjectEntry.cs @@ -0,0 +1,19 @@ +// 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.Objects.Drawables; + +namespace osu.Game.Rulesets.Objects +{ + /// + /// Created for a when only is given + /// to make sure a is always associated with a . + /// + internal class SyntheticHitObjectEntry : HitObjectLifetimeEntry + { + public SyntheticHitObjectEntry(HitObject hitObject) + : base(hitObject) + { + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Types/IHasColumn.cs b/osu.Game/Rulesets/Objects/Types/IHasColumn.cs similarity index 90% rename from osu.Game.Rulesets.Mania/Objects/Types/IHasColumn.cs rename to osu.Game/Rulesets/Objects/Types/IHasColumn.cs index 1ea3138828..dc07cfbb6a 100644 --- a/osu.Game.Rulesets.Mania/Objects/Types/IHasColumn.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasColumn.cs @@ -1,7 +1,7 @@ // 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.Rulesets.Mania.Objects.Types +namespace osu.Game.Rulesets.Objects.Types { /// /// A type of hit object which lies in one of a number of predetermined columns. diff --git a/osu.Game/Rulesets/Replays/AutoGenerator.cs b/osu.Game/Rulesets/Replays/AutoGenerator.cs index b3c609f2f4..83e85146d4 100644 --- a/osu.Game/Rulesets/Replays/AutoGenerator.cs +++ b/osu.Game/Rulesets/Replays/AutoGenerator.cs @@ -1,40 +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.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using osu.Game.Beatmaps; using osu.Game.Replays; using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Replays { - public abstract class AutoGenerator : IAutoGenerator + public abstract class AutoGenerator { /// - /// Creates the auto replay and returns it. - /// Every subclass of OsuAutoGeneratorBase should implement this! + /// The default duration of a key press in milliseconds. /// - public abstract Replay Generate(); - - #region Parameters + public const double KEY_UP_DELAY = 50; /// - /// The beatmap we're making. + /// The beatmap the autoplay is generated for. /// - protected IBeatmap Beatmap; - - #endregion + protected IBeatmap Beatmap { get; } protected AutoGenerator(IBeatmap beatmap) { Beatmap = beatmap; } - #region Constants - - // Shared amongst all modes - public const double KEY_UP_DELAY = 50; - - #endregion + /// + /// Generate the replay of the autoplay. + /// + public abstract Replay Generate(); protected virtual HitObject GetNextObject(int currentIndex) { @@ -44,4 +40,37 @@ namespace osu.Game.Rulesets.Replays return Beatmap.HitObjects[currentIndex + 1]; } } + + public abstract class AutoGenerator : AutoGenerator + where TFrame : ReplayFrame + { + /// + /// The replay frames of the autoplay. + /// + protected readonly List Frames = new List(); + + [CanBeNull] + protected TFrame LastFrame => Frames.Count == 0 ? null : Frames[^1]; + + protected AutoGenerator(IBeatmap beatmap) + : base(beatmap) + { + } + + public sealed override Replay Generate() + { + Frames.Clear(); + GenerateFrames(); + + return new Replay + { + Frames = Frames.OrderBy(frame => frame.Time).Cast().ToList() + }; + } + + /// + /// Generate the replay frames of the autoplay and populate . + /// + protected abstract void GenerateFrames(); + } } diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs index 0b41ca31ea..bc8994bbe5 100644 --- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs +++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System; using System.Collections.Generic; -using System.Diagnostics; +using System.Linq; using JetBrains.Annotations; using osu.Game.Input.Handlers; using osu.Game.Replays; @@ -17,80 +19,101 @@ namespace osu.Game.Rulesets.Replays public abstract class FramedReplayInputHandler : ReplayInputHandler where TFrame : ReplayFrame { - private readonly Replay replay; + /// + /// Whether we have at least one replay frame. + /// + public bool HasFrames => Frames.Count != 0; - protected List Frames => replay.Frames; + /// + /// Whether we are waiting for new frames to be received. + /// + public bool WaitingForFrame => !replay.HasReceivedAllFrames && currentFrameIndex == Frames.Count - 1; - public TFrame CurrentFrame - { - get - { - if (!HasFrames || !currentFrameIndex.HasValue) - return null; + /// + /// The current frame of the replay. + /// The current time is always between the start and the end time of the current frame. + /// + /// Returns null if the current time is strictly before the first frame. + public TFrame? CurrentFrame => currentFrameIndex == -1 ? null : (TFrame)Frames[currentFrameIndex]; - return (TFrame)Frames[currentFrameIndex.Value]; - } - } + /// + /// The next frame of the replay. + /// The start time of is always greater or equal to the start time of regardless of the seeking direction. + /// + /// Returns null if the current frame is the last frame. + public TFrame? NextFrame => currentFrameIndex == Frames.Count - 1 ? null : (TFrame)Frames[currentFrameIndex + 1]; - public TFrame NextFrame + /// + /// The frame for the start value of the interpolation of the replay movement. + /// + /// The replay is empty. + public TFrame StartFrame { get { if (!HasFrames) - return null; + throw new InvalidOperationException($"Attempted to get {nameof(StartFrame)} of an empty replay"); - if (!currentFrameIndex.HasValue) - return currentDirection > 0 ? (TFrame)Frames[0] : null; - - int nextFrame = clampedNextFrameIndex; - - if (nextFrame == currentFrameIndex.Value) - return null; - - return (TFrame)Frames[clampedNextFrameIndex]; + return (TFrame)Frames[Math.Max(0, currentFrameIndex)]; } } - private int? currentFrameIndex; - - private int clampedNextFrameIndex => - currentFrameIndex.HasValue ? Math.Clamp(currentFrameIndex.Value + currentDirection, 0, Frames.Count - 1) : 0; - - protected FramedReplayInputHandler(Replay replay) + /// + /// The frame for the end value of the interpolation of the replay movement. + /// + /// The replay is empty. + public TFrame EndFrame { - this.replay = replay; + get + { + if (!HasFrames) + throw new InvalidOperationException($"Attempted to get {nameof(EndFrame)} of an empty replay"); + + return (TFrame)Frames[Math.Min(currentFrameIndex + 1, Frames.Count - 1)]; + } } - private const double sixty_frame_time = 1000.0 / 60; - - protected virtual double AllowedImportantTimeSpan => sixty_frame_time * 1.2; - - protected double? CurrentTime { get; private set; } - - private int currentDirection = 1; - /// /// When set, we will ensure frames executed by nested drawables are frame-accurate to replay data. /// Disabling this can make replay playback smoother (useful for autoplay, currently). /// public bool FrameAccuratePlayback; - public bool HasFrames => Frames.Count > 0; + // This input handler should be enabled only if there is at least one replay frame. + public override bool IsActive => HasFrames; + + protected double CurrentTime { get; private set; } + + protected virtual double AllowedImportantTimeSpan => sixty_frame_time * 1.2; + + protected List Frames => replay.Frames; + + private readonly Replay replay; + + private int currentFrameIndex; + + private const double sixty_frame_time = 1000.0 / 60; + + protected FramedReplayInputHandler(Replay replay) + { + // TODO: This replay frame ordering should be enforced on the Replay type. + // Currently, the ordering can be broken if the frames are added after this construction. + replay.Frames = replay.Frames.OrderBy(f => f.Time).ToList(); + + this.replay = replay; + currentFrameIndex = -1; + CurrentTime = double.NegativeInfinity; + } private bool inImportantSection { get { - if (!HasFrames || !FrameAccuratePlayback) + if (!HasFrames || !FrameAccuratePlayback || currentFrameIndex == -1) return false; - var frame = currentDirection > 0 ? CurrentFrame : NextFrame; - - if (frame == null) - return false; - - return IsImportant(frame) && // a button is in a pressed state - Math.Abs(CurrentTime - NextFrame?.Time ?? 0) <= AllowedImportantTimeSpan; // the next frame is within an allowable time span + return IsImportant(StartFrame) && // a button is in a pressed state + Math.Abs(CurrentTime - EndFrame.Time) <= AllowedImportantTimeSpan; // the next frame is within an allowable time span } } @@ -105,71 +128,52 @@ namespace osu.Game.Rulesets.Replays /// The usable time value. If null, we should not advance time as we do not have enough data. public override double? SetFrameFromTime(double time) { - updateDirection(time); - - Debug.Assert(currentDirection != 0); - if (!HasFrames) { - // in the case all frames are received, allow time to progress regardless. + // In the case all frames are received, allow time to progress regardless. if (replay.HasReceivedAllFrames) return CurrentTime = time; return null; } - TFrame next = NextFrame; + double frameStart = getFrameTime(currentFrameIndex); + double frameEnd = getFrameTime(currentFrameIndex + 1); - // if we have a next frame, check if it is before or at the current time in playback, and advance time to it if so. - if (next != null) + // If the proposed time is after the current frame end time, we progress forwards to precisely the new frame's time (regardless of incoming time). + if (frameEnd <= time) { - int compare = time.CompareTo(next.Time); - - if (compare == 0 || compare == currentDirection) - { - currentFrameIndex = clampedNextFrameIndex; - return CurrentTime = CurrentFrame.Time; - } + time = frameEnd; + currentFrameIndex++; } + // If the proposed time is before the current frame start time, and we are at the frame boundary, we progress backwards. + else if (time < frameStart && CurrentTime == frameStart) + currentFrameIndex--; - // at this point, the frame index can't be advanced. - // even so, we may be able to propose the clock progresses forward due to being at an extent of the replay, - // or moving towards the next valid frame (ie. interpolating in a non-important section). + frameStart = getFrameTime(currentFrameIndex); + frameEnd = getFrameTime(currentFrameIndex + 1); - // the exception is if currently in an important section, which is respected above all. - if (inImportantSection) + // Pause until more frames are arrived. + if (WaitingForFrame && frameStart < time) { - Debug.Assert(next != null || !replay.HasReceivedAllFrames); + CurrentTime = frameStart; return null; } - // if a next frame does exist, allow interpolation. - if (next != null) - return CurrentTime = time; + CurrentTime = Math.Clamp(time, frameStart, frameEnd); - // if all frames have been received, allow playing beyond extents. - if (replay.HasReceivedAllFrames) - return CurrentTime = time; - - // if not all frames are received but we are before the first frame, allow playing. - if (time < Frames[0].Time) - return CurrentTime = time; - - // in the case we have no next frames and haven't received enough frame data, block. - return null; + // In an important section, a mid-frame time cannot be used and a null is returned instead. + return inImportantSection && frameStart < time && time < frameEnd ? null : (double?)CurrentTime; } - private void updateDirection(double time) + private double getFrameTime(int index) { - if (!CurrentTime.HasValue) - { - currentDirection = 1; - } - else - { - currentDirection = time.CompareTo(CurrentTime); - if (currentDirection == 0) currentDirection = 1; - } + if (index < 0) + return double.NegativeInfinity; + if (index >= Frames.Count) + return double.PositiveInfinity; + + return Frames[index].Time; } } } diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index dbc2bd4d01..7bdf84ace4 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -26,6 +26,8 @@ using JetBrains.Annotations; using osu.Framework.Extensions; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Testing; +using osu.Game.Extensions; +using osu.Game.Rulesets.Filter; using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets @@ -134,7 +136,7 @@ namespace osu.Game.Rulesets Name = Description, ShortName = ShortName, ID = (this as ILegacyRuleset)?.LegacyID, - InstantiationInfo = GetType().AssemblyQualifiedName, + InstantiationInfo = GetType().GetInvariantInstantiationInfo(), Available = true, }; } @@ -145,7 +147,6 @@ namespace osu.Game.Rulesets /// The beatmap to create the hit renderer for. /// The s to apply. /// Unable to successfully load the beatmap to be usable with this ruleset. - /// public abstract DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null); /// @@ -201,6 +202,8 @@ namespace osu.Game.Rulesets public virtual HitObjectComposer CreateHitObjectComposer() => null; + public virtual IBeatmapVerifier CreateBeatmapVerifier() => null; + public virtual Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.QuestionCircle }; public virtual IResourceStore CreateResourceStore() => new NamespacedResourceStore(new DllResourceStore(GetType().Assembly), @"Resources"); @@ -306,5 +309,11 @@ namespace osu.Game.Rulesets /// The result type to get the name for. /// The display name. public virtual string GetDisplayNameForHitResult(HitResult result) => result.GetDescription(); + + /// + /// Creates ruleset-specific beatmap filter criteria to be used on the song select screen. + /// + [CanBeNull] + public virtual IRulesetFilterCriteria CreateRulesetFilterCriteria() => null; } } diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index d5aca8c650..59ec9cdd7e 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -3,8 +3,8 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Linq; using Newtonsoft.Json; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; namespace osu.Game.Rulesets @@ -18,20 +18,7 @@ namespace osu.Game.Rulesets public string ShortName { get; set; } - private string instantiationInfo; - - public string InstantiationInfo - { - get => instantiationInfo; - set => instantiationInfo = abbreviateInstantiationInfo(value); - } - - private string abbreviateInstantiationInfo(string value) - { - // exclude version onwards, matching only on namespace and type. - // this is mainly to allow for new versions of already loaded rulesets to "upgrade" from old. - return string.Join(',', value.Split(',').Take(2)); - } + public string InstantiationInfo { get; set; } [JsonIgnore] public bool Available { get; set; } @@ -41,7 +28,7 @@ namespace osu.Game.Rulesets { if (!Available) return null; - var ruleset = (Ruleset)Activator.CreateInstance(Type.GetType(InstantiationInfo)); + var ruleset = (Ruleset)Activator.CreateInstance(Type.GetType(InstantiationInfo).AsNonNull()); // overwrite the pre-populated RulesetInfo with a potentially database attached copy. ruleset.RulesetInfo = this; diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index deabea57ef..0a34ca9598 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Reflection; using osu.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Database; @@ -111,7 +112,7 @@ namespace osu.Game.Rulesets { try { - var instanceInfo = ((Ruleset)Activator.CreateInstance(Type.GetType(r.InstantiationInfo))).RulesetInfo; + var instanceInfo = ((Ruleset)Activator.CreateInstance(Type.GetType(r.InstantiationInfo).AsNonNull())).RulesetInfo; r.Name = instanceInfo.Name; r.ShortName = instanceInfo.ShortName; @@ -173,7 +174,7 @@ namespace osu.Game.Rulesets { var filename = Path.GetFileNameWithoutExtension(file); - if (loadedAssemblies.Values.Any(t => t.Namespace == filename)) + if (loadedAssemblies.Values.Any(t => Path.GetFileNameWithoutExtension(t.Assembly.Location) == filename)) return; try diff --git a/osu.Game/Rulesets/Scoring/HitWindows.cs b/osu.Game/Rulesets/Scoring/HitWindows.cs index 018b50bd3d..410614de07 100644 --- a/osu.Game/Rulesets/Scoring/HitWindows.cs +++ b/osu.Game/Rulesets/Scoring/HitWindows.cs @@ -62,7 +62,6 @@ namespace osu.Game.Rulesets.Scoring /// /// Retrieves a mapping of s to their timing windows for all allowed s. /// - /// public IEnumerable<(HitResult result, double length)> GetAllAvailableWindows() { for (var result = HitResult.Meh; result <= HitResult.Perfect; ++result) diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index 8aef615b5f..201a05e569 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -28,6 +28,8 @@ namespace osu.Game.Rulesets.Scoring /// public int JudgedHits { get; private set; } + private JudgementResult lastAppliedResult; + private readonly BindableBool hasCompleted = new BindableBool(); /// @@ -53,12 +55,11 @@ namespace osu.Game.Rulesets.Scoring public void ApplyResult(JudgementResult result) { JudgedHits++; + lastAppliedResult = result; ApplyResultInternal(result); NewJudgement?.Invoke(result); - - updateHasCompleted(); } /// @@ -69,8 +70,6 @@ namespace osu.Game.Rulesets.Scoring { JudgedHits--; - updateHasCompleted(); - RevertResultInternal(result); } @@ -134,6 +133,10 @@ namespace osu.Game.Rulesets.Scoring } } - private void updateHasCompleted() => hasCompleted.Value = JudgedHits == MaxHits; + protected override void Update() + { + base.Update(); + hasCompleted.Value = JudgedHits == MaxHits && (JudgedHits == 0 || lastAppliedResult.TimeAbsolute < Clock.CurrentTime); + } } } diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 2024290460..f32f70d4ba 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -252,7 +252,7 @@ namespace osu.Game.Rulesets.Scoring computedBaseScore += Judgement.ToNumericResult(pair.Key) * pair.Value; } - return GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(computedBaseScore), calculateComboRatio(maxCombo), scoreResultCounts); + return GetScore(mode, maxAchievableCombo, calculateAccuracyRatio(computedBaseScore), calculateComboRatio(maxCombo), statistics); } /// @@ -266,7 +266,7 @@ namespace osu.Game.Rulesets.Scoring if (preferRolling && rollingMaxBaseScore != 0) return baseScore / rollingMaxBaseScore; - return maxBaseScore > 0 ? baseScore / maxBaseScore : 0; + return maxBaseScore > 0 ? baseScore / maxBaseScore : 1; } private double calculateComboRatio(int maxCombo) => maxAchievableCombo > 0 ? (double)maxCombo / maxAchievableCombo : 1; @@ -337,7 +337,7 @@ namespace osu.Game.Rulesets.Scoring score.TotalScore = (long)Math.Round(GetStandardisedScore()); score.Combo = Combo.Value; score.MaxCombo = HighestCombo.Value; - score.Accuracy = Math.Round(Accuracy.Value, 4); + score.Accuracy = Accuracy.Value; score.Rank = Rank.Value; score.Date = DateTimeOffset.Now; @@ -346,12 +346,6 @@ namespace osu.Game.Rulesets.Scoring score.HitEvents = hitEvents; } - - /// - /// Create a for this processor. - /// - [Obsolete("Method is now unused.")] // Can be removed 20210328 - public virtual HitWindows CreateHitWindows() => new HitWindows(); } public enum ScoringMode diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index ca27e6b21a..a2dade2627 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -85,6 +85,7 @@ namespace osu.Game.Rulesets.UI /// /// The beatmap. /// + [Cached(typeof(IBeatmap))] public readonly Beatmap Beatmap; public override IEnumerable Objects => Beatmap.HitObjects; diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index bbaca7c80f..e66a8c016c 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -63,6 +63,7 @@ namespace osu.Game.Rulesets.UI ~DrawableRulesetDependencies() { + // required to potentially clean up sample store from audio hierarchy. Dispose(false); } @@ -116,6 +117,10 @@ namespace osu.Game.Rulesets.UI public void RemoveAllAdjustments(AdjustableProperty type) => throw new NotSupportedException(); + public void BindAdjustments(IAggregateAudioAdjustment component) => throw new NotImplementedException(); + + public void UnbindAdjustments(IAggregateAudioAdjustment component) => throw new NotImplementedException(); + public BindableNumber Volume => throw new NotSupportedException(); public BindableNumber Balance => throw new NotSupportedException(); @@ -124,8 +129,6 @@ namespace osu.Game.Rulesets.UI public BindableNumber Tempo => throw new NotSupportedException(); - public IBindable GetAggregate(AdjustableProperty type) => throw new NotSupportedException(); - public IBindable AggregateVolume => throw new NotSupportedException(); public IBindable AggregateBalance => throw new NotSupportedException(); diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 11312a46df..83033b2dd5 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -17,8 +17,18 @@ using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.UI { - public class HitObjectContainer : LifetimeManagementContainer, IHitObjectContainer + public class HitObjectContainer : CompositeDrawable, IHitObjectContainer { + /// + /// All entries in this including dead entries. + /// + public IEnumerable Entries => allEntries; + + /// + /// All alive entries and s used by the entries. + /// + public IEnumerable<(HitObjectLifetimeEntry Entry, DrawableHitObject Drawable)> AliveEntries => aliveDrawableMap.Select(x => (x.Key, x.Value)); + public IEnumerable Objects => InternalChildren.Cast().OrderBy(h => h.HitObject.StartTime); public IEnumerable AliveObjects => AliveInternalChildren.Cast().OrderBy(h => h.HitObject.StartTime); @@ -60,8 +70,12 @@ namespace osu.Game.Rulesets.UI internal double FutureLifetimeExtension { get; set; } private readonly Dictionary startTimeMap = new Dictionary(); - private readonly Dictionary drawableMap = new Dictionary(); + + private readonly Dictionary aliveDrawableMap = new Dictionary(); + private readonly Dictionary nonPooledDrawableMap = new Dictionary(); + private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); + private readonly HashSet allEntries = new HashSet(); [Resolved(CanBeNull = true)] private IPooledHitObjectProvider pooledObjectProvider { get; set; } @@ -72,6 +86,7 @@ namespace osu.Game.Rulesets.UI lifetimeManager.EntryBecameAlive += entryBecameAlive; lifetimeManager.EntryBecameDead += entryBecameDead; + lifetimeManager.EntryCrossedBoundary += entryCrossedBoundary; } protected override void LoadAsyncComplete() @@ -84,123 +99,142 @@ namespace osu.Game.Rulesets.UI #region Pooling support - public void Add(HitObjectLifetimeEntry entry) => lifetimeManager.AddEntry(entry); - - public bool Remove(HitObjectLifetimeEntry entry) => lifetimeManager.RemoveEntry(entry); - - private void entryBecameAlive(LifetimeEntry entry) => addDrawable((HitObjectLifetimeEntry)entry); - - private void entryBecameDead(LifetimeEntry entry) => removeDrawable((HitObjectLifetimeEntry)entry); - - private void addDrawable(HitObjectLifetimeEntry entry) + public void Add(HitObjectLifetimeEntry entry) { - Debug.Assert(!drawableMap.ContainsKey(entry)); + allEntries.Add(entry); + lifetimeManager.AddEntry(entry); + } - var drawable = pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject, null); + public bool Remove(HitObjectLifetimeEntry entry) + { + if (!lifetimeManager.RemoveEntry(entry)) return false; + + // This logic is not in `Remove(DrawableHitObject)` because a non-pooled drawable may be removed by specifying its entry. + if (nonPooledDrawableMap.Remove(entry, out var drawable)) + removeDrawable(drawable); + + allEntries.Remove(entry); + return true; + } + + private void entryBecameAlive(LifetimeEntry lifetimeEntry) + { + var entry = (HitObjectLifetimeEntry)lifetimeEntry; + Debug.Assert(!aliveDrawableMap.ContainsKey(entry)); + + bool isPooled = !nonPooledDrawableMap.TryGetValue(entry, out var drawable); + drawable ??= pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject, null); if (drawable == null) throw new InvalidOperationException($"A drawable representation could not be retrieved for hitobject type: {entry.HitObject.GetType().ReadableName()}."); + aliveDrawableMap[entry] = drawable; + + if (isPooled) + { + addDrawable(drawable); + HitObjectUsageBegan?.Invoke(entry.HitObject); + } + + OnAdd(drawable); + } + + private void entryBecameDead(LifetimeEntry lifetimeEntry) + { + var entry = (HitObjectLifetimeEntry)lifetimeEntry; + Debug.Assert(aliveDrawableMap.ContainsKey(entry)); + + var drawable = aliveDrawableMap[entry]; + bool isPooled = !nonPooledDrawableMap.ContainsKey(entry); + + drawable.OnKilled(); + aliveDrawableMap.Remove(entry); + + if (isPooled) + { + removeDrawable(drawable); + HitObjectUsageFinished?.Invoke(entry.HitObject); + } + + OnRemove(drawable); + } + + private void addDrawable(DrawableHitObject drawable) + { drawable.OnNewResult += onNewResult; drawable.OnRevertResult += onRevertResult; bindStartTime(drawable); - AddInternal(drawableMap[entry] = drawable, false); - OnAdd(drawable); - - HitObjectUsageBegan?.Invoke(entry.HitObject); + AddInternal(drawable); } - private void removeDrawable(HitObjectLifetimeEntry entry) + private void removeDrawable(DrawableHitObject drawable) { - Debug.Assert(drawableMap.ContainsKey(entry)); - - var drawable = drawableMap[entry]; - - // OnKilled can potentially change the hitobject's result, so it needs to run first before unbinding. - drawable.OnKilled(); drawable.OnNewResult -= onNewResult; drawable.OnRevertResult -= onRevertResult; - drawableMap.Remove(entry); - - OnRemove(drawable); unbindStartTime(drawable); - RemoveInternal(drawable); - HitObjectUsageFinished?.Invoke(entry.HitObject); + RemoveInternal(drawable); } #endregion #region Non-pooling support - public virtual void Add(DrawableHitObject hitObject) + public virtual void Add(DrawableHitObject drawable) { - bindStartTime(hitObject); + if (drawable.Entry == null) + throw new InvalidOperationException($"May not add a {nameof(DrawableHitObject)} without {nameof(HitObject)} associated"); - hitObject.OnNewResult += onNewResult; - hitObject.OnRevertResult += onRevertResult; - - AddInternal(hitObject); - OnAdd(hitObject); + nonPooledDrawableMap.Add(drawable.Entry, drawable); + addDrawable(drawable); + Add(drawable.Entry); } - public virtual bool Remove(DrawableHitObject hitObject) + public virtual bool Remove(DrawableHitObject drawable) { - OnRemove(hitObject); - if (!RemoveInternal(hitObject)) + if (drawable.Entry == null) return false; - hitObject.OnNewResult -= onNewResult; - hitObject.OnRevertResult -= onRevertResult; - - unbindStartTime(hitObject); - - return true; + return Remove(drawable.Entry); } public int IndexOf(DrawableHitObject hitObject) => IndexOfInternal(hitObject); - protected override void OnChildLifetimeBoundaryCrossed(LifetimeBoundaryCrossedEvent e) + private void entryCrossedBoundary(LifetimeEntry entry, LifetimeBoundaryKind kind, LifetimeBoundaryCrossingDirection direction) { - if (!(e.Child is DrawableHitObject hitObject)) - return; + if (nonPooledDrawableMap.TryGetValue((HitObjectLifetimeEntry)entry, out var drawable)) + OnChildLifetimeBoundaryCrossed(new LifetimeBoundaryCrossedEvent(drawable, kind, direction)); + } - if ((e.Kind == LifetimeBoundaryKind.End && e.Direction == LifetimeBoundaryCrossingDirection.Forward) - || (e.Kind == LifetimeBoundaryKind.Start && e.Direction == LifetimeBoundaryCrossingDirection.Backward)) - { - hitObject.OnKilled(); - } + protected virtual void OnChildLifetimeBoundaryCrossed(LifetimeBoundaryCrossedEvent e) + { } #endregion /// - /// Invoked when a is added to this container. + /// Invoked after a is added to this container. /// - /// - /// This method is not invoked for nested s. - /// protected virtual void OnAdd(DrawableHitObject drawableHitObject) { + Debug.Assert(drawableHitObject.LoadState >= LoadState.Ready); } /// - /// Invoked when a is removed from this container. + /// Invoked after a is removed from this container. /// - /// - /// This method is not invoked for nested s. - /// protected virtual void OnRemove(DrawableHitObject drawableHitObject) { } - public virtual void Clear(bool disposeChildren = true) + public virtual void Clear() { lifetimeManager.ClearEntries(); - - ClearInternal(disposeChildren); - unbindAllStartTimes(); + foreach (var drawable in nonPooledDrawableMap.Values) + removeDrawable(drawable); + nonPooledDrawableMap.Clear(); + Debug.Assert(InternalChildren.Count == 0 && startTimeMap.Count == 0 && aliveDrawableMap.Count == 0, "All hit objects should have been removed"); } protected override bool CheckChildrenLife() diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index c40ab4bd94..b154288dba 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -66,7 +66,7 @@ namespace osu.Game.Rulesets.UI var enumerable = HitObjectContainer.Objects; - if (nestedPlayfields.IsValueCreated) + if (nestedPlayfields.Count != 0) enumerable = enumerable.Concat(NestedPlayfields.SelectMany(p => p.AllHitObjects)); return enumerable; @@ -76,9 +76,9 @@ namespace osu.Game.Rulesets.UI /// /// All s nested inside this . /// - public IEnumerable NestedPlayfields => nestedPlayfields.IsValueCreated ? nestedPlayfields.Value : Enumerable.Empty(); + public IEnumerable NestedPlayfields => nestedPlayfields; - private readonly Lazy> nestedPlayfields = new Lazy>(); + private readonly List nestedPlayfields = new List(); /// /// Whether judgements should be displayed by this and and all nested s. @@ -217,7 +217,7 @@ namespace osu.Game.Rulesets.UI otherPlayfield.HitObjectUsageBegan += h => HitObjectUsageBegan?.Invoke(h); otherPlayfield.HitObjectUsageFinished += h => HitObjectUsageFinished?.Invoke(h); - nestedPlayfields.Value.Add(otherPlayfield); + nestedPlayfields.Add(otherPlayfield); } protected override void LoadComplete() @@ -279,12 +279,7 @@ namespace osu.Game.Rulesets.UI return true; } - bool removedFromNested = false; - - if (nestedPlayfields.IsValueCreated) - removedFromNested = nestedPlayfields.Value.Any(p => p.Remove(hitObject)); - - return removedFromNested; + return nestedPlayfields.Any(p => p.Remove(hitObject)); } /// @@ -359,15 +354,18 @@ namespace osu.Game.Rulesets.UI // If this is the first time this DHO is being used, then apply the DHO mods. // This is done before Apply() so that the state is updated once when the hitobject is applied. - foreach (var m in mods.OfType()) - m.ApplyToDrawableHitObjects(dho.Yield()); + if (mods != null) + { + foreach (var m in mods.OfType()) + m.ApplyToDrawableHitObjects(dho.Yield()); + } } if (!lifetimeEntryMap.TryGetValue(hitObject, out var entry)) lifetimeEntryMap[hitObject] = entry = CreateLifetimeEntry(hitObject); dho.ParentHitObject = parent; - dho.Apply(hitObject, entry); + dho.Apply(entry); }); } @@ -429,10 +427,7 @@ namespace osu.Game.Rulesets.UI return; } - if (!nestedPlayfields.IsValueCreated) - return; - - foreach (var p in nestedPlayfields.Value) + foreach (var p in nestedPlayfields) p.SetKeepAlive(hitObject, keepAlive); } @@ -444,10 +439,7 @@ namespace osu.Game.Rulesets.UI foreach (var (_, entry) in lifetimeEntryMap) entry.KeepAlive = true; - if (!nestedPlayfields.IsValueCreated) - return; - - foreach (var p in nestedPlayfields.Value) + foreach (var p in nestedPlayfields) p.KeepAllAlive(); } @@ -461,10 +453,7 @@ namespace osu.Game.Rulesets.UI { HitObjectContainer.PastLifetimeExtension = value; - if (!nestedPlayfields.IsValueCreated) - return; - - foreach (var nested in nestedPlayfields.Value) + foreach (var nested in nestedPlayfields) nested.PastLifetimeExtension = value; } } @@ -479,10 +468,7 @@ namespace osu.Game.Rulesets.UI { HitObjectContainer.FutureLifetimeExtension = value; - if (!nestedPlayfields.IsValueCreated) - return; - - foreach (var nested in nestedPlayfields.Value) + foreach (var nested in nestedPlayfields) nested.FutureLifetimeExtension = value; } } diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs index a4d46e3888..d18e0f9541 100644 --- a/osu.Game/Rulesets/UI/ReplayRecorder.cs +++ b/osu.Game/Rulesets/UI/ReplayRecorder.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.UI public int RecordFrameRate = 60; [Resolved(canBeNull: true)] - private SpectatorStreamingClient spectatorStreaming { get; set; } + private SpectatorClient spectatorClient { get; set; } [Resolved] private GameplayBeatmap gameplayBeatmap { get; set; } @@ -49,13 +49,19 @@ namespace osu.Game.Rulesets.UI inputManager = GetContainingInputManager(); - spectatorStreaming?.BeginPlaying(gameplayBeatmap, target); + spectatorClient?.BeginPlaying(gameplayBeatmap, target); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - spectatorStreaming?.EndPlaying(); + spectatorClient?.EndPlaying(); + } + + protected override void Update() + { + base.Update(); + recordFrame(false); } protected override bool OnMouseMove(MouseMoveEvent e) @@ -92,7 +98,7 @@ namespace osu.Game.Rulesets.UI { target.Replay.Frames.Add(frame); - spectatorStreaming?.HandleFrame(frame); + spectatorClient?.HandleFrame(frame); } } diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 07de2bf601..75c3a4661c 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -13,10 +13,10 @@ using osu.Framework.Input.Events; using osu.Framework.Input.StateChanges.Events; using osu.Framework.Input.States; using osu.Game.Configuration; +using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Input.Handlers; using osu.Game.Screens.Play; -using osuTK.Input; using static osu.Game.Input.Handlers.ReplayInputHandler; namespace osu.Game.Rulesets.UI @@ -109,9 +109,9 @@ namespace osu.Game.Rulesets.UI { switch (e) { - case MouseDownEvent mouseDown when mouseDown.Button == MouseButton.Left || mouseDown.Button == MouseButton.Right: + case MouseDownEvent _: if (mouseDisabled.Value) - return false; + return true; // importantly, block upwards propagation so global bindings also don't fire. break; @@ -170,6 +170,13 @@ namespace osu.Game.Rulesets.UI : base(ruleset, variant, unique) { } + + protected override void ReloadMappings() + { + base.ReloadMappings(); + + KeyBindings = KeyBindings.Where(b => KeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); + } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 289578f3d8..cde4182f2d 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -18,12 +18,7 @@ namespace osu.Game.Rulesets.UI.Scrolling private readonly IBindable direction = new Bindable(); /// - /// Hit objects which require lifetime computation in the next update call. - /// - private readonly HashSet toComputeLifetime = new HashSet(); - - /// - /// A set containing all which have an up-to-date layout. + /// A set of top-level s which have an up-to-date layout. /// private readonly HashSet layoutComputed = new HashSet(); @@ -50,11 +45,10 @@ namespace osu.Game.Rulesets.UI.Scrolling timeRange.ValueChanged += _ => layoutCache.Invalidate(); } - public override void Clear(bool disposeChildren = true) + public override void Clear() { - base.Clear(disposeChildren); + base.Clear(); - toComputeLifetime.Clear(); layoutComputed.Clear(); } @@ -83,7 +77,7 @@ namespace osu.Game.Rulesets.UI.Scrolling flipPositionIfRequired(ref position); - return scrollingInfo.Algorithm.TimeAt(position, Time.Current, scrollingInfo.TimeRange.Value, getLength()); + return scrollingInfo.Algorithm.TimeAt(position, Time.Current, scrollingInfo.TimeRange.Value, scrollLength); } /// @@ -91,7 +85,7 @@ namespace osu.Game.Rulesets.UI.Scrolling /// public Vector2 ScreenSpacePositionAtTime(double time) { - var pos = scrollingInfo.Algorithm.PositionAt(time, Time.Current, scrollingInfo.TimeRange.Value, getLength()); + var pos = scrollingInfo.Algorithm.PositionAt(time, Time.Current, scrollingInfo.TimeRange.Value, scrollLength); flipPositionIfRequired(ref pos); @@ -106,16 +100,19 @@ namespace osu.Game.Rulesets.UI.Scrolling } } - private float getLength() + private float scrollLength { - switch (scrollingInfo.Direction.Value) + get { - case ScrollingDirection.Left: - case ScrollingDirection.Right: - return DrawWidth; + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Left: + case ScrollingDirection.Right: + return DrawWidth; - default: - return DrawHeight; + default: + return DrawHeight; + } } } @@ -150,81 +147,41 @@ namespace osu.Game.Rulesets.UI.Scrolling } } - protected override void OnAdd(DrawableHitObject drawableHitObject) => onAddRecursive(drawableHitObject); - - protected override void OnRemove(DrawableHitObject drawableHitObject) => onRemoveRecursive(drawableHitObject); - - private void onAddRecursive(DrawableHitObject hitObject) + protected override void OnAdd(DrawableHitObject drawableHitObject) { - invalidateHitObject(hitObject); - - hitObject.DefaultsApplied += invalidateHitObject; - - foreach (var nested in hitObject.NestedHitObjects) - onAddRecursive(nested); + invalidateHitObject(drawableHitObject); + drawableHitObject.DefaultsApplied += invalidateHitObject; } - private void onRemoveRecursive(DrawableHitObject hitObject) + protected override void OnRemove(DrawableHitObject drawableHitObject) { - toComputeLifetime.Remove(hitObject); - layoutComputed.Remove(hitObject); + layoutComputed.Remove(drawableHitObject); - hitObject.DefaultsApplied -= invalidateHitObject; - - foreach (var nested in hitObject.NestedHitObjects) - onRemoveRecursive(nested); + drawableHitObject.DefaultsApplied -= invalidateHitObject; } - /// - /// Make this lifetime and layout computed in next update. - /// private void invalidateHitObject(DrawableHitObject hitObject) { - // Lifetime computation is delayed until next update because - // when the hit object is not pooled this container is not loaded here and `scrollLength` cannot be computed. - toComputeLifetime.Add(hitObject); + hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); layoutComputed.Remove(hitObject); } - private float scrollLength; - protected override void Update() { base.Update(); - if (!layoutCache.IsValid) - { - toComputeLifetime.Clear(); + if (layoutCache.IsValid) return; - foreach (var hitObject in Objects) - { - if (hitObject.HitObject != null) - toComputeLifetime.Add(hitObject); - } + layoutComputed.Clear(); - layoutComputed.Clear(); + // Reset lifetime to the conservative estimation. + // If a drawable becomes alive by this lifetime, its lifetime will be updated to a more precise lifetime in the next update. + foreach (var entry in Entries) + entry.SetInitialLifetime(); - scrollingInfo.Algorithm.Reset(); + scrollingInfo.Algorithm.Reset(); - switch (direction.Value) - { - case ScrollingDirection.Up: - case ScrollingDirection.Down: - scrollLength = DrawSize.Y; - break; - - default: - scrollLength = DrawSize.X; - break; - } - - layoutCache.Validate(); - } - - foreach (var hitObject in toComputeLifetime) - hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); - - toComputeLifetime.Clear(); + layoutCache.Validate(); } protected override void UpdateAfterChildrenLife() diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index db7e51e833..f8dd6953ad 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -8,7 +8,6 @@ using System.Text; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.IO.Legacy; -using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays.Types; using SharpCompress.Compressors.LZMA; @@ -91,12 +90,14 @@ namespace osu.Game.Scoring.Legacy if (score.Replay != null) { - LegacyReplayFrame lastF = new LegacyReplayFrame(0, 0, 0, ReplayButtonState.None); + int lastTime = 0; foreach (var f in score.Replay.Frames.OfType().Select(f => f.ToLegacy(beatmap))) { - replayData.Append(FormattableString.Invariant($"{f.Time - lastF.Time}|{f.MouseX ?? 0}|{f.MouseY ?? 0}|{(int)f.ButtonState},")); - lastF = f; + // Rounding because stable could only parse integral values + int time = (int)Math.Round(f.Time); + replayData.Append(FormattableString.Invariant($"{time - lastTime}|{f.MouseX ?? 0}|{f.MouseY ?? 0}|{(int)f.ButtonState},")); + lastTime = time; } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index f5192f3a40..a6faaf6379 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -10,6 +10,7 @@ using Newtonsoft.Json.Converters; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Database; +using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -30,7 +31,7 @@ namespace osu.Game.Scoring public long TotalScore { get; set; } [JsonProperty("accuracy")] - [Column(TypeName = "DECIMAL(1,4)")] + [Column(TypeName = "DECIMAL(1,4)")] // TODO: This data type is wrong (should contain more precision). But at the same time, we probably don't need to be storing this in the database. public double Accuracy { get; set; } [JsonIgnore] @@ -55,54 +56,69 @@ namespace osu.Game.Scoring [JsonIgnore] public virtual RulesetInfo Ruleset { get; set; } + private APIMod[] localAPIMods; private Mod[] mods; - [JsonProperty("mods")] + [JsonIgnore] [NotMapped] public Mod[] Mods { get { + var rulesetInstance = Ruleset?.CreateInstance(); + if (rulesetInstance == null) + return mods ?? Array.Empty(); + + Mod[] scoreMods = Array.Empty(); + if (mods != null) - return mods; + scoreMods = mods; + else if (localAPIMods != null) + scoreMods = apiMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - if (modsJson == null) - return Array.Empty(); + if (IsLegacyScore) + scoreMods = scoreMods.Append(rulesetInstance.GetAllMods().OfType().Single()).ToArray(); - return getModsFromRuleset(JsonConvert.DeserializeObject(modsJson)); + return scoreMods; } set { - modsJson = null; + localAPIMods = null; mods = value; } } - private Mod[] getModsFromRuleset(DeserializedMod[] mods) => Ruleset.CreateInstance().GetAllMods().Where(mod => mods.Any(d => d.Acronym == mod.Acronym)).ToArray(); + // Used for API serialisation/deserialisation. + [JsonProperty("mods")] + [NotMapped] + private APIMod[] apiMods + { + get + { + if (localAPIMods != null) + return localAPIMods; - private string modsJson; + if (mods == null) + return Array.Empty(); + return localAPIMods = mods.Select(m => new APIMod(m)).ToArray(); + } + set + { + localAPIMods = value; + + // We potentially can't update this yet due to Ruleset being late-bound, so instead update on read as necessary. + mods = null; + } + } + + // Used for database serialisation/deserialisation. [JsonIgnore] [Column("Mods")] public string ModsJson { - get - { - if (modsJson != null) - return modsJson; - - if (mods == null) - return null; - - return modsJson = JsonConvert.SerializeObject(mods.Select(m => new DeserializedMod { Acronym = m.Acronym })); - } - set - { - modsJson = value; - - // we potentially can't update this yet due to Ruleset being late-bound, so instead update on read as necessary. - mods = null; - } + get => JsonConvert.SerializeObject(apiMods); + set => apiMods = JsonConvert.DeserializeObject(value); } [NotMapped] @@ -251,14 +267,6 @@ namespace osu.Game.Scoring } } - [Serializable] - protected class DeserializedMod : IMod - { - public string Acronym { get; set; } - - public bool Equals(IMod other) => Acronym == other?.Acronym; - } - public override string ToString() => $"{User} playing {Beatmap}"; public bool Equals(ScoreInfo other) diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index a6beb19876..9d3b952ada 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -20,6 +20,7 @@ using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring.Legacy; @@ -71,6 +72,16 @@ namespace osu.Game.Scoring } } + protected override void ExportModelTo(ScoreInfo model, Stream outputStream) + { + var file = model.Files.SingleOrDefault(); + if (file == null) + return; + + using (var inputStream = Files.Storage.GetStream(file.FileInfo.StoragePath)) + inputStream.CopyTo(outputStream); + } + protected override IEnumerable GetStableImportPaths(Storage storage) => storage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)) .Select(path => storage.GetFullPath(path)); @@ -137,7 +148,7 @@ namespace osu.Game.Scoring ScoringMode.BindValueChanged(onScoringModeChanged, true); } - private IBindable difficultyBindable; + private IBindable difficultyBindable; private CancellationTokenSource difficultyCancellationSource; private void onScoringModeChanged(ValueChangedEvent mode) @@ -152,9 +163,21 @@ namespace osu.Game.Scoring } int beatmapMaxCombo; + double accuracy = score.Accuracy; if (score.IsLegacyScore) { + if (score.RulesetID == 3) + { + // In osu!stable, a full-GREAT score has 100% accuracy in mania. Along with a full combo, the score becomes indistinguishable from a full-PERFECT score. + // To get around this, recalculate accuracy based on the hit statistics. + // Note: This cannot be applied universally to all legacy scores, as some rulesets (e.g. catch) group multiple judgements together. + double maxBaseScore = score.Statistics.Select(kvp => kvp.Value).Sum() * Judgement.ToNumericResult(HitResult.Perfect); + double baseScore = score.Statistics.Select(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value).Sum(); + if (maxBaseScore > 0) + accuracy = baseScore / maxBaseScore; + } + // This score is guaranteed to be an osu!stable score. // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. if (score.Beatmap.MaxCombo == null) @@ -168,7 +191,11 @@ namespace osu.Game.Scoring // We can compute the max combo locally after the async beatmap difficulty computation. difficultyBindable = difficulties().GetBindableDifficulty(score.Beatmap, score.Ruleset, score.Mods, (difficultyCancellationSource = new CancellationTokenSource()).Token); - difficultyBindable.BindValueChanged(d => updateScore(d.NewValue.MaxCombo), true); + difficultyBindable.BindValueChanged(d => + { + if (d.NewValue is StarDifficulty diff) + updateScore(diff.MaxCombo, accuracy); + }, true); return; } @@ -182,10 +209,10 @@ namespace osu.Game.Scoring beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetOrDefault(r)).Sum(); } - updateScore(beatmapMaxCombo); + updateScore(beatmapMaxCombo, accuracy); } - private void updateScore(int beatmapMaxCombo) + private void updateScore(int beatmapMaxCombo, double accuracy) { if (beatmapMaxCombo == 0) { @@ -198,7 +225,7 @@ namespace osu.Game.Scoring scoreProcessor.Mods.Value = score.Mods; - Value = (long)Math.Round(scoreProcessor.GetScore(ScoringMode.Value, beatmapMaxCombo, score.Accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); + Value = (long)Math.Round(scoreProcessor.GetScore(ScoringMode.Value, beatmapMaxCombo, accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); } } diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs index 5f66c13d2f..bb15983de3 100644 --- a/osu.Game/Scoring/ScorePerformanceCache.cs +++ b/osu.Game/Scoring/ScorePerformanceCache.cs @@ -34,7 +34,7 @@ namespace osu.Game.Scoring { var score = lookup.ScoreInfo; - var attributes = await difficultyCache.GetDifficultyAsync(score.Beatmap, score.Ruleset, score.Mods, token); + var attributes = await difficultyCache.GetDifficultyAsync(score.Beatmap, score.Ruleset, score.Mods, token).ConfigureAwait(false); // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. if (attributes.Attributes == null) diff --git a/osu.Game/Screens/BackgroundScreen.cs b/osu.Game/Screens/BackgroundScreen.cs index 48c5523883..a6fb94b151 100644 --- a/osu.Game/Screens/BackgroundScreen.cs +++ b/osu.Game/Screens/BackgroundScreen.cs @@ -13,6 +13,8 @@ namespace osu.Game.Screens { private readonly bool animateOnEnter; + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + protected BackgroundScreen(bool animateOnEnter = true) { this.animateOnEnter = animateOnEnter; diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs index b08455be95..65bc9cfaea 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs @@ -27,9 +27,12 @@ namespace osu.Game.Screens.Backgrounds private WorkingBeatmap beatmap; /// - /// Whether or not user dim settings should be applied to this Background. + /// Whether or not user-configured settings relating to brightness of elements should be ignored. /// - public readonly Bindable EnableUserDim = new Bindable(); + /// + /// Beatmap background screens should not apply user settings by default. + /// + public readonly Bindable IgnoreUserSettings = new Bindable(true); public readonly Bindable StoryboardReplacesBackground = new Bindable(); @@ -50,7 +53,7 @@ namespace osu.Game.Screens.Backgrounds InternalChild = dimmable = CreateFadeContainer(); - dimmable.EnableUserDim.BindTo(EnableUserDim); + dimmable.IgnoreUserSettings.BindTo(IgnoreUserSettings); dimmable.IsBreakTime.BindTo(IsBreakTime); dimmable.BlurAmount.BindTo(BlurAmount); @@ -148,7 +151,7 @@ namespace osu.Game.Screens.Backgrounds /// /// As an optimisation, we add the two blur portions to be applied rather than actually applying two separate blurs. /// - private Vector2 blurTarget => EnableUserDim.Value + private Vector2 blurTarget => !IgnoreUserSettings.Value ? new Vector2(BlurAmount.Value + (float)userBlurLevel.Value * USER_BLUR_FACTOR) : new Vector2(BlurAmount.Value); @@ -166,7 +169,9 @@ namespace osu.Game.Screens.Backgrounds BlurAmount.ValueChanged += _ => UpdateVisuals(); } - protected override bool ShowDimContent => !ShowStoryboard.Value || !StoryboardReplacesBackground.Value; // The background needs to be hidden in the case of it being replaced by the storyboard + protected override bool ShowDimContent + // The background needs to be hidden in the case of it being replaced by the storyboard + => (!ShowStoryboard.Value && !IgnoreUserSettings.Value) || !StoryboardReplacesBackground.Value; protected override void UpdateVisuals() { diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index d9477dd4bc..ff33f0c70d 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -4,7 +4,6 @@ using System; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Graphics.Colour; using osu.Game.Graphics; using osuTK.Graphics; @@ -48,7 +47,7 @@ namespace osu.Game.Screens.Edit /// The beat divisor. /// The set of colours. /// The applicable colour from for . - public static ColourInfo GetColourFor(int beatDivisor, OsuColour colours) + public static Color4 GetColourFor(int beatDivisor, OsuColour colours) { switch (beatDivisor) { diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs index 9739f2876a..bdc6e238c8 100644 --- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs +++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs @@ -27,7 +27,7 @@ namespace osu.Game.Screens.Edit.Components [Resolved] private EditorClock editorClock { get; set; } - private readonly BindableNumber tempo = new BindableDouble(1); + private readonly BindableNumber freqAdjust = new BindableDouble(1); [BackgroundDependencyLoader] private void load() @@ -58,16 +58,16 @@ namespace osu.Game.Screens.Edit.Components RelativeSizeAxes = Axes.Both, Height = 0.5f, Padding = new MarginPadding { Left = 45 }, - Child = new PlaybackTabControl { Current = tempo }, + Child = new PlaybackTabControl { Current = freqAdjust }, } }; - Track.BindValueChanged(tr => tr.NewValue?.AddAdjustment(AdjustableProperty.Tempo, tempo), true); + Track.BindValueChanged(tr => tr.NewValue?.AddAdjustment(AdjustableProperty.Frequency, freqAdjust), true); } protected override void Dispose(bool isDisposing) { - Track.Value?.RemoveAdjustment(AdjustableProperty.Tempo, tempo); + Track.Value?.RemoveAdjustment(AdjustableProperty.Frequency, freqAdjust); base.Dispose(isDisposing); } @@ -101,7 +101,7 @@ namespace osu.Game.Screens.Edit.Components private class PlaybackTabControl : OsuTabControl { - private static readonly double[] tempo_values = { 0.5, 0.75, 1 }; + private static readonly double[] tempo_values = { 0.25, 0.5, 0.75, 1 }; protected override TabItem CreateTabItem(double value) => new PlaybackTabItem(value); diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs index 0cf7b83f3b..1f608d28fd 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs @@ -16,7 +16,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.RadioButtons { - public class DrawableRadioButton : TriangleButton + public class DrawableRadioButton : OsuButton { /// /// Invoked when this has been selected. @@ -49,8 +49,6 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons selectedBackgroundColour = colours.BlueDark; selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); - Triangles.Alpha = 0; - Content.EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs index a7b0fb05e3..dcf5f8a788 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs @@ -12,7 +12,6 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons /// /// Whether this is selected. /// - /// public readonly BindableBool Selected; /// diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index c72fff5c91..c43561eaa7 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -15,7 +15,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.TernaryButtons { - internal class DrawableTernaryButton : TriangleButton + internal class DrawableTernaryButton : OsuButton { private Color4 defaultBackgroundColour; private Color4 defaultBubbleColour; @@ -43,8 +43,6 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons selectedBackgroundColour = colours.BlueDark; selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); - Triangles.Alpha = 0; - Content.EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs index e8a4b5c8c7..3d535ec915 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs @@ -28,7 +28,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts } [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.Yellow; + private void load(OsuColour colours) => Colour = colours.GreyCarmineLight; } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.cs new file mode 100644 index 0000000000..a8e41d220a --- /dev/null +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointVisualisation.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; + +namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts +{ + public class ControlPointVisualisation : PointVisualisation + { + protected readonly ControlPoint Point; + + public ControlPointVisualisation(ControlPoint point) + { + Point = point; + + Height = 0.25f; + Origin = Anchor.TopCentre; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = Point.GetRepresentingColour(colours); + } + } +} diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs new file mode 100644 index 0000000000..801372305b --- /dev/null +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/EffectPointVisualisation.cs @@ -0,0 +1,72 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; + +namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts +{ + public class EffectPointVisualisation : CompositeDrawable + { + private readonly EffectControlPoint effect; + private Bindable kiai; + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + public EffectPointVisualisation(EffectControlPoint point) + { + RelativePositionAxes = Axes.Both; + RelativeSizeAxes = Axes.Y; + + effect = point; + } + + [BackgroundDependencyLoader] + private void load() + { + kiai = effect.KiaiModeBindable.GetBoundCopy(); + kiai.BindValueChanged(_ => + { + ClearInternal(); + + AddInternal(new ControlPointVisualisation(effect)); + + if (!kiai.Value) + return; + + var endControlPoint = beatmap.ControlPointInfo.EffectPoints.FirstOrDefault(c => c.Time > effect.Time && !c.KiaiMode); + + // handle kiai duration + // eventually this will be simpler when we have control points with durations. + if (endControlPoint != null) + { + RelativeSizeAxes = Axes.Both; + Origin = Anchor.TopLeft; + + Width = (float)(endControlPoint.Time - effect.Time); + + AddInternal(new PointVisualisation + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.TopLeft, + Width = 1, + Height = 0.25f, + Depth = float.MaxValue, + Colour = effect.GetRepresentingColour(colours).Darken(0.5f), + }); + } + }, true); + } + } +} diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs index 93fe6f9989..4629f9b540 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs @@ -1,29 +1,33 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; -using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; -using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { - public class GroupVisualisation : PointVisualisation + public class GroupVisualisation : CompositeDrawable { + [Resolved] + private OsuColour colours { get; set; } + public readonly ControlPointGroup Group; private readonly IBindableList controlPoints = new BindableList(); - [Resolved] - private OsuColour colours { get; set; } - public GroupVisualisation(ControlPointGroup group) - : base(group.Time) { + RelativePositionAxes = Axes.X; + + RelativeSizeAxes = Axes.Both; + Origin = Anchor.TopLeft; + Group = group; + X = (float)group.Time; } protected override void LoadComplete() @@ -33,13 +37,32 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts controlPoints.BindTo(Group.ControlPoints); controlPoints.BindCollectionChanged((_, __) => { - if (controlPoints.Count == 0) - { - Colour = Color4.Transparent; - return; - } + ClearInternal(); - Colour = controlPoints.Any(c => c is TimingControlPoint) ? colours.YellowDark : colours.Green; + if (controlPoints.Count == 0) + return; + + foreach (var point in Group.ControlPoints) + { + switch (point) + { + case TimingControlPoint _: + AddInternal(new ControlPointVisualisation(point) { Y = 0, }); + break; + + case DifficultyControlPoint _: + AddInternal(new ControlPointVisualisation(point) { Y = 0.25f, }); + break; + + case SampleControlPoint _: + AddInternal(new ControlPointVisualisation(point) { Y = 0.5f, }); + break; + + case EffectControlPoint effect: + AddInternal(new EffectPointVisualisation(effect) { Y = 0.75f }); + break; + } + } }, true); } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs index 02cd4bccb4..e90ae411de 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs @@ -27,6 +27,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary Anchor = Anchor.Centre, Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.Both, + Y = -10, Height = 0.35f }, new BookmarkPart @@ -38,6 +39,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary }, new Container { + Name = "centre line", RelativeSizeAxes = Axes.Both, Colour = colours.Gray5, Children = new Drawable[] @@ -45,7 +47,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary new Circle { Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreRight, + Origin = Anchor.Centre, Size = new Vector2(5) }, new Box @@ -59,7 +61,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary new Circle { Anchor = Anchor.CentreRight, - Origin = Anchor.CentreLeft, + Origin = Anchor.Centre, Size = new Vector2(5) }, } @@ -69,7 +71,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Height = 0.25f + Height = 0.10f } }; } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs index de63df5463..ec68bf9c00 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/DurationVisualisation.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations @@ -10,19 +9,15 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations /// /// Represents a spanning point on a timeline part. /// - public class DurationVisualisation : Container + public class DurationVisualisation : Circle { protected DurationVisualisation(double startTime, double endTime) { - Masking = true; - CornerRadius = 5; - RelativePositionAxes = Axes.X; RelativeSizeAxes = Axes.Both; + X = (float)startTime; Width = (float)(endTime - startTime); - - AddInternal(new Box { RelativeSizeAxes = Axes.Both }); } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs index b0ecffdd24..a4b6b0c392 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs @@ -3,16 +3,15 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osuTK; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations { /// /// Represents a singular point on a timeline part. /// - public class PointVisualisation : Box + public class PointVisualisation : Circle { - public const float WIDTH = 1; + public const float MAX_WIDTH = 4; public PointVisualisation(double startTime) : this() @@ -22,13 +21,14 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations public PointVisualisation() { - Origin = Anchor.TopCentre; - - RelativePositionAxes = Axes.X; + RelativePositionAxes = Axes.Both; RelativeSizeAxes = Axes.Y; - Width = WIDTH; - EdgeSmoothness = new Vector2(WIDTH, 0); + Anchor = Anchor.CentreLeft; + Origin = Anchor.Centre; + + Width = MAX_WIDTH; + Height = 0.75f; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 5371beac60..8a4d381535 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -16,52 +16,60 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; using osuTK; using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { /// - /// A container which provides a "blueprint" display of hitobjects. - /// Includes selection and manipulation support via a . + /// A container which provides a "blueprint" display of items. + /// Includes selection and manipulation support via a . /// - public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler + public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler + where T : class { protected DragBox DragBox { get; private set; } - public Container SelectionBlueprints { get; private set; } + public Container> SelectionBlueprints { get; private set; } - protected SelectionHandler SelectionHandler { get; private set; } + protected SelectionHandler SelectionHandler { get; private set; } - protected readonly HitObjectComposer Composer; - - [Resolved(CanBeNull = true)] - private IEditorChangeHandler changeHandler { get; set; } - - [Resolved] - protected EditorClock EditorClock { get; private set; } - - [Resolved] - protected EditorBeatmap Beatmap { get; private set; } - - private readonly BindableList selectedHitObjects = new BindableList(); - private readonly Dictionary blueprintMap = new Dictionary(); + private readonly Dictionary> blueprintMap = new Dictionary>(); [Resolved(canBeNull: true)] private IPositionSnapProvider snapProvider { get; set; } - protected BlueprintContainer(HitObjectComposer composer) - { - Composer = composer; + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } + protected readonly BindableList SelectedItems = new BindableList(); + + protected BlueprintContainer() + { RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] private void load() { + SelectedItems.CollectionChanged += (selectedObjects, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var o in args.NewItems) + SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Select(); + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var o in args.OldItems) + SelectionBlueprints.FirstOrDefault(b => b.Item == o)?.Deselect(); + + break; + } + }; + SelectionHandler = CreateSelectionHandler(); SelectionHandler.DeselectAll = deselectAll; @@ -73,76 +81,39 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionHandler.CreateProxy(), DragBox.CreateProxy().With(p => p.Depth = float.MinValue) }); - - // For non-pooled rulesets, hitobjects are already present in the playfield which allows the blueprints to be loaded in the async context. - if (Composer != null) - { - foreach (var obj in Composer.HitObjects) - addBlueprintFor(obj.HitObject); - } - - selectedHitObjects.BindTo(Beatmap.SelectedHitObjects); - selectedHitObjects.CollectionChanged += (selectedObjects, args) => - { - switch (args.Action) - { - case NotifyCollectionChangedAction.Add: - foreach (var o in args.NewItems) - SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Select(); - break; - - case NotifyCollectionChangedAction.Remove: - foreach (var o in args.OldItems) - SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Deselect(); - - break; - } - }; } - protected override void LoadComplete() - { - base.LoadComplete(); - - Beatmap.HitObjectAdded += addBlueprintFor; - Beatmap.HitObjectRemoved += removeBlueprintFor; - - if (Composer != null) - { - // For pooled rulesets, blueprints must be added for hitobjects already "current" as they would've not been "current" during the async load addition process above. - foreach (var obj in Composer.HitObjects) - addBlueprintFor(obj.HitObject); - - Composer.Playfield.HitObjectUsageBegan += addBlueprintFor; - Composer.Playfield.HitObjectUsageFinished += removeBlueprintFor; - } - } - - protected virtual Container CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; + protected virtual Container> CreateSelectionBlueprintContainer() => new Container> { RelativeSizeAxes = Axes.Both }; /// - /// Creates a which outlines s and handles movement of selections. + /// Creates a which outlines items and handles movement of selections. /// - protected virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler(); + protected abstract SelectionHandler CreateSelectionHandler(); /// - /// Creates a for a specific . + /// Creates a for a specific item. /// - /// The to create the overlay for. - protected virtual SelectionBlueprint CreateBlueprintFor(HitObject hitObject) => null; + /// The item to create the overlay for. + protected virtual SelectionBlueprint CreateBlueprintFor(T item) => null; protected virtual DragBox CreateDragBox(Action performSelect) => new DragBox(performSelect); + /// + /// Whether this component is in a state where items outside a drag selection should be deselected. If false, selection will only be added to. + /// + protected virtual bool AllowDeselectionDuringDrag => true; + protected override bool OnMouseDown(MouseDownEvent e) { - if (!beginClickSelection(e)) return true; + bool selectionPerformed = performMouseDownActions(e); + // even if a selection didn't occur, a drag event may still move the selection. prepareSelectionMovement(); - return e.Button == MouseButton.Left; + return selectionPerformed || e.Button == MouseButton.Left; } - private SelectionBlueprint clickedBlueprint; + protected SelectionBlueprint ClickedBlueprint { get; private set; } protected override bool OnClick(ClickEvent e) { @@ -150,11 +121,11 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; // store for double-click handling - clickedBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered); + ClickedBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered); // Deselection should only occur if no selected blueprints are hovered - // A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the hitobject and should not trigger deselection - if (endClickSelection() || clickedBlueprint != null) + // A special case for when a blueprint was selected via this click is added since OnClick() may occur outside the item and should not trigger deselection + if (endClickSelection(e) || ClickedBlueprint != null) return true; deselectAll(); @@ -167,17 +138,21 @@ namespace osu.Game.Screens.Edit.Compose.Components return false; // ensure the blueprint which was hovered for the first click is still the hovered blueprint. - if (clickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != clickedBlueprint) + if (ClickedBlueprint == null || SelectionHandler.SelectedBlueprints.FirstOrDefault(b => b.IsHovered) != ClickedBlueprint) return false; - EditorClock?.SeekSmoothlyTo(clickedBlueprint.HitObject.StartTime); return true; } protected override void OnMouseUp(MouseUpEvent e) { // Special case for when a drag happened instead of a click - Schedule(() => endClickSelection()); + Schedule(() => + { + endClickSelection(e); + clickSelectionBegan = false; + isDraggingBlueprint = false; + }); finishSelectionMovement(); } @@ -221,18 +196,21 @@ namespace osu.Game.Screens.Edit.Compose.Components if (isDraggingBlueprint) { - // handle positional change etc. - foreach (var obj in selectedHitObjects) - Beatmap.Update(obj); - + DragOperationCompleted(); changeHandler?.EndChange(); - isDraggingBlueprint = false; } if (DragBox.State == Visibility.Visible) DragBox.Hide(); } + /// + /// Called whenever a drag operation completes, before any change transaction is committed. + /// + protected virtual void DragOperationCompleted() + { + } + protected override bool OnKeyDown(KeyDownEvent e) { switch (e.Key) @@ -253,7 +231,7 @@ namespace osu.Game.Screens.Edit.Compose.Components switch (action.ActionType) { case PlatformActionType.SelectAll: - selectAll(); + SelectAll(); return true; } @@ -266,61 +244,68 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Blueprint Addition/Removal - private void addBlueprintFor(HitObject hitObject) + protected virtual void AddBlueprintFor(T item) { - if (blueprintMap.ContainsKey(hitObject)) + if (blueprintMap.ContainsKey(item)) return; - var blueprint = CreateBlueprintFor(hitObject); + var blueprint = CreateBlueprintFor(item); if (blueprint == null) return; - blueprintMap[hitObject] = blueprint; + blueprintMap[item] = blueprint; - blueprint.Selected += onBlueprintSelected; - blueprint.Deselected += onBlueprintDeselected; - - if (Beatmap.SelectedHitObjects.Contains(hitObject)) - blueprint.Select(); + blueprint.Selected += OnBlueprintSelected; + blueprint.Deselected += OnBlueprintDeselected; SelectionBlueprints.Add(blueprint); - OnBlueprintAdded(hitObject); + if (SelectionHandler.SelectedItems.Contains(item)) + blueprint.Select(); + + OnBlueprintAdded(blueprint.Item); } - private void removeBlueprintFor(HitObject hitObject) + protected void RemoveBlueprintFor(T item) { - if (!blueprintMap.Remove(hitObject, out var blueprint)) + if (!blueprintMap.Remove(item, out var blueprint)) return; blueprint.Deselect(); - blueprint.Selected -= onBlueprintSelected; - blueprint.Deselected -= onBlueprintDeselected; + blueprint.Selected -= OnBlueprintSelected; + blueprint.Deselected -= OnBlueprintDeselected; SelectionBlueprints.Remove(blueprint); if (movementBlueprints?.Contains(blueprint) == true) finishSelectionMovement(); - OnBlueprintRemoved(hitObject); + OnBlueprintRemoved(blueprint.Item); } /// - /// Called after a blueprint has been added. + /// Called after an item's blueprint has been added. /// - /// The for which the blueprint has been added. - protected virtual void OnBlueprintAdded(HitObject hitObject) + /// The item for which the blueprint has been added. + protected virtual void OnBlueprintAdded(T item) { } /// - /// Called after a blueprint has been removed. + /// Called after an item's blueprint has been removed. /// - /// The for which the blueprint has been removed. - protected virtual void OnBlueprintRemoved(HitObject hitObject) + /// The item for which the blueprint has been removed. + protected virtual void OnBlueprintRemoved(T item) { } + /// + /// Retrieves an item's blueprint. + /// + /// The item to retrieve the blueprint of. + /// The blueprint. + protected SelectionBlueprint GetBlueprintFor(T item) => blueprintMap[item]; + #endregion #region Selection @@ -335,14 +320,15 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// The input event that triggered this selection. /// Whether a selection was performed. - private bool beginClickSelection(MouseButtonEvent e) + private bool performMouseDownActions(MouseButtonEvent e) { // Iterate from the top of the input stack (blueprints closest to the front of the screen first). - foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse()) + // Priority is given to already-selected blueprints. + foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected)) { if (!blueprint.IsHovered) continue; - return clickSelectionBegan = SelectionHandler.HandleSelectionRequested(blueprint, e); + return clickSelectionBegan = SelectionHandler.MouseDownSelectionRequested(blueprint, e); } return false; @@ -351,13 +337,28 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Finishes the current blueprint selection. /// + /// The mouse event which triggered end of selection. /// Whether a click selection was active. - private bool endClickSelection() + private bool endClickSelection(MouseButtonEvent e) { - if (!clickSelectionBegan) - return false; + if (!clickSelectionBegan && !isDraggingBlueprint) + { + // if a selection didn't occur, we may want to trigger a deselection. + if (e.ControlPressed && e.Button == MouseButton.Left) + { + // Iterate from the top of the input stack (blueprints closest to the front of the screen first). + // Priority is given to already-selected blueprints. + foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren.Reverse().OrderByDescending(b => b.IsSelected)) + { + if (!blueprint.IsHovered) continue; + + return clickSelectionBegan = SelectionHandler.MouseUpSelectionRequested(blueprint, e); + } + } + + return false; + } - clickSelectionBegan = false; return true; } @@ -380,8 +381,7 @@ namespace osu.Game.Screens.Edit.Compose.Components break; case SelectionState.Selected: - // if the editor is playing, we generally don't want to deselect objects even if outside the selection area. - if (!EditorClock.IsRunning && !isValidForSelection()) + if (AllowDeselectionDuringDrag && !isValidForSelection()) blueprint.Deselect(); break; } @@ -389,35 +389,29 @@ namespace osu.Game.Screens.Edit.Compose.Components } /// - /// Selects all s. + /// Selects all s. /// - private void selectAll() + protected virtual void SelectAll() { - Composer.Playfield.KeepAllAlive(); - // Scheduled to allow the change in lifetime to take place. Schedule(() => SelectionBlueprints.ToList().ForEach(m => m.Select())); } /// - /// Deselects all selected s. + /// Deselects all selected s. /// private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect()); - private void onBlueprintSelected(SelectionBlueprint blueprint) + protected virtual void OnBlueprintSelected(SelectionBlueprint blueprint) { SelectionHandler.HandleSelected(blueprint); SelectionBlueprints.ChangeChildDepth(blueprint, 1); - - Composer.Playfield.SetKeepAlive(blueprint.HitObject, true); } - private void onBlueprintDeselected(SelectionBlueprint blueprint) + protected virtual void OnBlueprintDeselected(SelectionBlueprint blueprint) { - SelectionHandler.HandleDeselected(blueprint); SelectionBlueprints.ChangeChildDepth(blueprint, 0); - - Composer.Playfield.SetKeepAlive(blueprint.HitObject, false); + SelectionHandler.HandleDeselected(blueprint); } #endregion @@ -425,7 +419,7 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Selection Movement private Vector2[] movementBlueprintOriginalPositions; - private SelectionBlueprint[] movementBlueprints; + private SelectionBlueprint[] movementBlueprints; private bool isDraggingBlueprint; /// @@ -436,16 +430,23 @@ namespace osu.Game.Screens.Edit.Compose.Components if (!SelectionHandler.SelectedBlueprints.Any()) return; - // Any selected blueprint that is hovered can begin the movement of the group, however only the earliest hitobject is used for movement + // Any selected blueprint that is hovered can begin the movement of the group, however only the first item (according to SortForMovement) is used for movement. // A special case is added for when a click selection occurred before the drag if (!clickSelectionBegan && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) return; - // Movement is tracked from the blueprint of the earliest hitobject, since it only makes sense to distance snap from that hitobject - movementBlueprints = SelectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).ToArray(); + // Movement is tracked from the blueprint of the earliest item, since it only makes sense to distance snap from that item + movementBlueprints = SortForMovement(SelectionHandler.SelectedBlueprints).ToArray(); movementBlueprintOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSelectionPoint).ToArray(); } + /// + /// Apply sorting of selected blueprints before performing movement. Generally used to surface the "main" item to the beginning of the collection. + /// + /// The blueprints to be moved. + /// Sorted blueprints. + protected virtual IEnumerable> SortForMovement(IReadOnlyList> blueprints) => blueprints; + /// /// Moves the current selected blueprints. /// @@ -456,52 +457,50 @@ namespace osu.Game.Screens.Edit.Compose.Components if (movementBlueprints == null) return false; - if (snapProvider == null) - return true; - Debug.Assert(movementBlueprintOriginalPositions != null); Vector2 distanceTravelled = e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; - // check for positional snap for every object in selection (for things like object-object snapping) - for (var i = 0; i < movementBlueprintOriginalPositions.Length; i++) + if (snapProvider != null) { - var testPosition = movementBlueprintOriginalPositions[i] + distanceTravelled; + // check for positional snap for every object in selection (for things like object-object snapping) + for (var i = 0; i < movementBlueprintOriginalPositions.Length; i++) + { + Vector2 originalPosition = movementBlueprintOriginalPositions[i]; + var testPosition = originalPosition + distanceTravelled; - var positionalResult = snapProvider.SnapScreenSpacePositionToValidPosition(testPosition); + var positionalResult = snapProvider.SnapScreenSpacePositionToValidPosition(testPosition); - if (positionalResult.ScreenSpacePosition == testPosition) continue; + if (positionalResult.ScreenSpacePosition == testPosition) continue; - // attempt to move the objects, and abort any time based snapping if we can. - if (SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints[i], positionalResult.ScreenSpacePosition))) - return true; + var delta = positionalResult.ScreenSpacePosition - movementBlueprints[i].ScreenSpaceSelectionPoint; + + // attempt to move the objects, and abort any time based snapping if we can. + if (SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints[i], delta))) + return true; + } } // if no positional snapping could be performed, try unrestricted snapping from the earliest - // hitobject in the selection. + // item in the selection. // The final movement position, relative to movementBlueprintOriginalPosition. Vector2 movePosition = movementBlueprintOriginalPositions.First() + distanceTravelled; // Retrieve a snapped position. - var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition); + var result = snapProvider?.SnapScreenSpacePositionToValidTime(movePosition); - // Move the hitobjects. - if (!SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints.First(), result.ScreenSpacePosition))) - return true; - - if (result.Time.HasValue) + if (result == null) { - // Apply the start time at the newly snapped-to position - double offset = result.Time.Value - movementBlueprints.First().HitObject.StartTime; - - foreach (HitObject obj in Beatmap.SelectedHitObjects) - obj.StartTime += offset; + return SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprints.First(), movePosition - movementBlueprints.First().ScreenSpaceSelectionPoint)); } - return true; + return ApplySnapResult(movementBlueprints, result); } + protected virtual bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) => + SelectionHandler.HandleMovement(new MoveSelectionEvent(blueprints.First(), result.ScreenSpacePosition - blueprints.First().ScreenSpaceSelectionPoint)); + /// /// Finishes the current movement of selected blueprints. /// @@ -518,22 +517,5 @@ namespace osu.Game.Screens.Edit.Compose.Components } #endregion - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (Beatmap != null) - { - Beatmap.HitObjectAdded -= addBlueprintFor; - Beatmap.HitObjectRemoved -= removeBlueprintFor; - } - - if (Composer != null) - { - Composer.Playfield.HitObjectUsageBegan -= addBlueprintFor; - Composer.Playfield.HitObjectUsageFinished -= removeBlueprintFor; - } - } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 79f457c050..3e97e15cca 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; +using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; @@ -19,18 +20,21 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit.Components.TernaryButtons; using osuTK; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { /// /// A blueprint container generally displayed as an overlay to a ruleset's playfield. /// - public class ComposeBlueprintContainer : BlueprintContainer + public class ComposeBlueprintContainer : EditorBlueprintContainer { public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; private readonly Container placementBlueprintContainer; + protected new EditorSelectionHandler SelectionHandler => (EditorSelectionHandler)base.SelectionHandler; + private PlacementBlueprint currentPlacement; private InputManager inputManager; @@ -57,6 +61,8 @@ namespace osu.Game.Screens.Edit.Compose.Components inputManager = GetContainingInputManager(); + Beatmap.HitObjectAdded += hitObjectAdded; + // updates to selected are handled for us by SelectionHandler. NewCombo.BindTo(SelectionHandler.SelectionNewComboState); @@ -70,6 +76,58 @@ namespace osu.Game.Screens.Edit.Compose.Components } } + protected override void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject) + { + base.TransferBlueprintFor(hitObject, drawableObject); + + var blueprint = (HitObjectSelectionBlueprint)GetBlueprintFor(hitObject); + blueprint.DrawableObject = drawableObject; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.ControlPressed) + { + switch (e.Key) + { + case Key.Left: + moveSelection(new Vector2(-1, 0)); + return true; + + case Key.Right: + moveSelection(new Vector2(1, 0)); + return true; + + case Key.Up: + moveSelection(new Vector2(0, -1)); + return true; + + case Key.Down: + moveSelection(new Vector2(0, 1)); + return true; + } + } + + return false; + } + + /// + /// Move the current selection spatially by the specified delta, in gamefield coordinates (ie. the same coordinates as the blueprints). + /// + /// + private void moveSelection(Vector2 delta) + { + var firstBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(); + + if (firstBlueprint == null) + return; + + // convert to game space coordinates + delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero); + + SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, delta)); + } + private void updatePlacementNewCombo() { if (currentPlacement?.HitObject is IHasComboInformation c) @@ -150,7 +208,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void refreshTool() { removePlacement(); - createPlacement(); + ensurePlacementCreated(); } private void updatePlacementPosition() @@ -169,33 +227,43 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.Update(); - if (Composer.CursorInPlacementArea) - createPlacement(); - else if (currentPlacement?.PlacementActive == false) - removePlacement(); - if (currentPlacement != null) { - updatePlacementPosition(); + switch (currentPlacement.PlacementActive) + { + case PlacementBlueprint.PlacementState.Waiting: + if (!Composer.CursorInPlacementArea) + removePlacement(); + break; + + case PlacementBlueprint.PlacementState.Finished: + removePlacement(); + break; + } } + + if (Composer.CursorInPlacementArea) + ensurePlacementCreated(); + + if (currentPlacement != null) + updatePlacementPosition(); } - protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject hitObject) + protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject item) { - var drawable = Composer.HitObjects.FirstOrDefault(d => d.HitObject == hitObject); + var drawable = Composer.HitObjects.FirstOrDefault(d => d.HitObject == item); if (drawable == null) return null; - return CreateBlueprintFor(drawable); + return CreateHitObjectBlueprintFor(item).With(b => b.DrawableObject = drawable); } - public virtual OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => null; + public virtual HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) => null; - protected override void OnBlueprintAdded(HitObject hitObject) + private void hitObjectAdded(HitObject obj) { - base.OnBlueprintAdded(hitObject); - + // refresh the tool to handle the case of placement completing. refreshTool(); // on successful placement, the new combo button should be reset as this is the most common user interaction. @@ -203,7 +271,7 @@ namespace osu.Game.Screens.Edit.Compose.Components NewCombo.Value = TernaryState.False; } - private void createPlacement() + private void ensurePlacementCreated() { if (currentPlacement != null) return; diff --git a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 8a92a2011d..59f88ac641 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -132,7 +132,7 @@ namespace osu.Game.Screens.Edit.Compose.Components var colour = BindableBeatDivisor.GetColourFor(BindableBeatDivisor.GetDivisorForBeatIndex(beatIndex + placementIndex + 1, beatDivisor.Value), Colours); int repeatIndex = placementIndex / beatDivisor.Value; - return colour.MultiplyAlpha(0.5f / (repeatIndex + 1)); + return ColourInfo.SingleColour(colour).MultiplyAlpha(0.5f / (repeatIndex + 1)); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs new file mode 100644 index 0000000000..22b211f257 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/EditorBlueprintContainer.cs @@ -0,0 +1,165 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class EditorBlueprintContainer : BlueprintContainer + { + [Resolved] + protected EditorClock EditorClock { get; private set; } + + [Resolved] + protected EditorBeatmap Beatmap { get; private set; } + + protected readonly HitObjectComposer Composer; + + private HitObjectUsageEventBuffer usageEventBuffer; + + protected EditorBlueprintContainer(HitObjectComposer composer) + { + Composer = composer; + } + + [BackgroundDependencyLoader] + private void load() + { + SelectedItems.BindTo(Beatmap.SelectedHitObjects); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Beatmap.HitObjectAdded += AddBlueprintFor; + Beatmap.HitObjectRemoved += RemoveBlueprintFor; + + if (Composer != null) + { + foreach (var obj in Composer.HitObjects) + AddBlueprintFor(obj.HitObject); + + usageEventBuffer = new HitObjectUsageEventBuffer(Composer.Playfield); + usageEventBuffer.HitObjectUsageBegan += AddBlueprintFor; + usageEventBuffer.HitObjectUsageFinished += RemoveBlueprintFor; + usageEventBuffer.HitObjectUsageTransferred += TransferBlueprintFor; + } + } + + protected override void Update() + { + base.Update(); + usageEventBuffer?.Update(); + } + + protected override IEnumerable> SortForMovement(IReadOnlyList> blueprints) + => blueprints.OrderBy(b => b.Item.StartTime); + + protected override bool AllowDeselectionDuringDrag => !EditorClock.IsRunning; + + protected override bool ApplySnapResult(SelectionBlueprint[] blueprints, SnapResult result) + { + if (!base.ApplySnapResult(blueprints, result)) + return false; + + if (result.Time.HasValue) + { + // Apply the start time at the newly snapped-to position + double offset = result.Time.Value - blueprints.First().Item.StartTime; + + if (offset != 0) + { + Beatmap.PerformOnSelection(obj => + { + obj.StartTime += offset; + Beatmap.Update(obj); + }); + } + } + + return true; + } + + protected override void AddBlueprintFor(HitObject item) + { + if (item is IBarLine) + return; + + base.AddBlueprintFor(item); + } + + /// + /// Invoked when a has been transferred to another . + /// + /// The hit object which has been assigned to a new drawable. + /// The new drawable that is representing the hit object. + protected virtual void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject) + { + } + + protected override void DragOperationCompleted() + { + base.DragOperationCompleted(); + + // handle positional change etc. + foreach (var blueprint in SelectionBlueprints) + Beatmap.Update(blueprint.Item); + } + + protected override bool OnDoubleClick(DoubleClickEvent e) + { + if (!base.OnDoubleClick(e)) + return false; + + EditorClock?.SeekSmoothlyTo(ClickedBlueprint.Item.StartTime); + return true; + } + + protected override Container> CreateSelectionBlueprintContainer() => new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }; + + protected override SelectionHandler CreateSelectionHandler() => new EditorSelectionHandler(); + + protected override void SelectAll() + { + Composer.Playfield.KeepAllAlive(); + + base.SelectAll(); + } + + protected override void OnBlueprintSelected(SelectionBlueprint blueprint) + { + base.OnBlueprintSelected(blueprint); + + Composer.Playfield.SetKeepAlive(blueprint.Item, true); + } + + protected override void OnBlueprintDeselected(SelectionBlueprint blueprint) + { + base.OnBlueprintDeselected(blueprint); + + Composer.Playfield.SetKeepAlive(blueprint.Item, false); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (Beatmap != null) + { + Beatmap.HitObjectAdded -= AddBlueprintFor; + Beatmap.HitObjectRemoved -= RemoveBlueprintFor; + } + + usageEventBuffer?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs new file mode 100644 index 0000000000..246d4aa8d7 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -0,0 +1,188 @@ +// 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 Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Audio; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class EditorSelectionHandler : SelectionHandler + { + [Resolved] + protected EditorBeatmap EditorBeatmap { get; private set; } + + [BackgroundDependencyLoader] + private void load() + { + createStateBindables(); + + // bring in updates from selection changes + EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); + + SelectedItems.BindTo(EditorBeatmap.SelectedHitObjects); + SelectedItems.CollectionChanged += (sender, args) => + { + Scheduler.AddOnce(UpdateTernaryStates); + }; + } + + protected override void DeleteItems(IEnumerable items) => EditorBeatmap.RemoveRange(items); + + #region Selection State + + /// + /// The state of "new combo" for all selected hitobjects. + /// + public readonly Bindable SelectionNewComboState = new Bindable(); + + /// + /// The state of each sample type for all selected hitobjects. Keys match with constant specifications. + /// + public readonly Dictionary> SelectionSampleStates = new Dictionary>(); + + /// + /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) + /// + private void createStateBindables() + { + foreach (var sampleName in HitSampleInfo.AllAdditions) + { + var bindable = new Bindable + { + Description = sampleName.Replace("hit", string.Empty).Titleize() + }; + + bindable.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + RemoveHitSample(sampleName); + break; + + case TernaryState.True: + AddHitSample(sampleName); + break; + } + }; + + SelectionSampleStates[sampleName] = bindable; + } + + // new combo + SelectionNewComboState.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + SetNewCombo(false); + break; + + case TernaryState.True: + SetNewCombo(true); + break; + } + }; + } + + /// + /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated). + /// + protected virtual void UpdateTernaryStates() + { + SelectionNewComboState.Value = GetStateFromSelection(SelectedItems.OfType(), h => h.NewCombo); + + foreach (var (sampleName, bindable) in SelectionSampleStates) + { + bindable.Value = GetStateFromSelection(SelectedItems, h => h.Samples.Any(s => s.Name == sampleName)); + } + } + + #endregion + + #region Ternary state changes + + /// + /// Adds a hit sample to all selected s. + /// + /// The name of the hit sample. + public void AddHitSample(string sampleName) + { + EditorBeatmap.PerformOnSelection(h => + { + // Make sure there isn't already an existing sample + if (h.Samples.Any(s => s.Name == sampleName)) + return; + + h.Samples.Add(new HitSampleInfo(sampleName)); + EditorBeatmap.Update(h); + }); + } + + /// + /// Removes a hit sample from all selected s. + /// + /// The name of the hit sample. + public void RemoveHitSample(string sampleName) + { + EditorBeatmap.PerformOnSelection(h => + { + h.SamplesBindable.RemoveAll(s => s.Name == sampleName); + EditorBeatmap.Update(h); + }); + } + + /// + /// Set the new combo state of all selected s. + /// + /// Whether to set or unset. + /// Throws if any selected object doesn't implement + public void SetNewCombo(bool state) + { + EditorBeatmap.PerformOnSelection(h => + { + var comboInfo = h as IHasComboInformation; + + if (comboInfo == null || comboInfo.NewCombo == state) return; + + comboInfo.NewCombo = state; + EditorBeatmap.Update(h); + }); + } + + #endregion + + #region Context Menu + + /// + /// Provide context menu items relevant to current selection. Calling base is not required. + /// + /// The current selection. + /// The relevant menu items. + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) + { + if (SelectedBlueprints.All(b => b.Item is IHasComboInformation)) + { + yield return new TernaryStateToggleMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; + } + + yield return new OsuMenuItem("Sound") + { + Items = SelectionSampleStates.Select(kvp => + new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() + }; + } + + #endregion + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs index 9e95fe4fa1..4078661a26 100644 --- a/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectOrderedSelectionContainer.cs @@ -11,17 +11,17 @@ using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Edit.Compose.Components { /// - /// A container for ordered by their start times. + /// A container for ordered by their start times. /// - public sealed class HitObjectOrderedSelectionContainer : Container + public sealed class HitObjectOrderedSelectionContainer : Container> { - public override void Add(SelectionBlueprint drawable) + public override void Add(SelectionBlueprint drawable) { base.Add(drawable); bindStartTime(drawable); } - public override bool Remove(SelectionBlueprint drawable) + public override bool Remove(SelectionBlueprint drawable) { if (!base.Remove(drawable)) return false; @@ -36,11 +36,11 @@ namespace osu.Game.Screens.Edit.Compose.Components unbindAllStartTimes(); } - private readonly Dictionary startTimeMap = new Dictionary(); + private readonly Dictionary, IBindable> startTimeMap = new Dictionary, IBindable>(); - private void bindStartTime(SelectionBlueprint blueprint) + private void bindStartTime(SelectionBlueprint blueprint) { - var bindable = blueprint.HitObject.StartTimeBindable.GetBoundCopy(); + var bindable = blueprint.Item.StartTimeBindable.GetBoundCopy(); bindable.BindValueChanged(_ => { @@ -51,7 +51,7 @@ namespace osu.Game.Screens.Edit.Compose.Components startTimeMap[blueprint] = bindable; } - private void unbindStartTime(SelectionBlueprint blueprint) + private void unbindStartTime(SelectionBlueprint blueprint) { startTimeMap[blueprint].UnbindAll(); startTimeMap.Remove(blueprint); @@ -66,12 +66,18 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override int Compare(Drawable x, Drawable y) { - var xObj = (SelectionBlueprint)x; - var yObj = (SelectionBlueprint)y; + var xObj = (SelectionBlueprint)x; + var yObj = (SelectionBlueprint)y; // Put earlier blueprints towards the end of the list, so they handle input first - int i = yObj.HitObject.StartTime.CompareTo(xObj.HitObject.StartTime); - return i == 0 ? CompareReverseChildID(x, y) : i; + int i = yObj.Item.StartTime.CompareTo(xObj.Item.StartTime); + + if (i != 0) return i; + + // Fall back to end time if the start time is equal. + i = yObj.Item.GetEndTime().CompareTo(xObj.Item.GetEndTime()); + + return i == 0 ? CompareReverseChildID(y, x) : i; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs index 0792d0f80e..2b71bb2f16 100644 --- a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs +++ b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs @@ -7,31 +7,24 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { /// - /// An event which occurs when a is moved. + /// An event which occurs when a is moved. /// - public class MoveSelectionEvent + public class MoveSelectionEvent { /// - /// The that triggered this . + /// The that triggered this . /// - public readonly SelectionBlueprint Blueprint; + public readonly SelectionBlueprint Blueprint; /// - /// The expected screen-space position of the hitobject at the current cursor position. + /// The screen-space delta of this move event. /// - public readonly Vector2 ScreenSpacePosition; + public readonly Vector2 ScreenSpaceDelta; - /// - /// The distance between and the hitobject's current position, in the coordinate-space of the hitobject's parent. - /// - public readonly Vector2 InstantDelta; - - public MoveSelectionEvent(SelectionBlueprint blueprint, Vector2 screenSpacePosition) + public MoveSelectionEvent(SelectionBlueprint blueprint, Vector2 screenSpaceDelta) { Blueprint = blueprint; - ScreenSpacePosition = screenSpacePosition; - - InstantDelta = Blueprint.GetInstantDelta(ScreenSpacePosition); + ScreenSpaceDelta = screenSpaceDelta; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index 2f4721f63e..dc457b5320 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Input; @@ -16,6 +17,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { public class SelectionBox : CompositeDrawable { + public const float BORDER_RADIUS = 3; + public Func OnRotation; public Func OnScale; public Func OnFlip; @@ -92,37 +95,57 @@ namespace osu.Game.Screens.Edit.Compose.Components } } - private Container dragHandles; + private string text; + + public string Text + { + get => text; + set + { + if (value == text) + return; + + text = value; + if (selectionDetailsText != null) + selectionDetailsText.Text = value; + } + } + + private SelectionBoxDragHandleContainer dragHandles; private FillFlowContainer buttons; - public const float BORDER_RADIUS = 3; + private OsuSpriteText selectionDetailsText; [Resolved] private OsuColour colours { get; set; } [BackgroundDependencyLoader] - private void load() - { - RelativeSizeAxes = Axes.Both; - - recreate(); - } + private void load() => recreate(); protected override bool OnKeyDown(KeyDownEvent e) { if (e.Repeat || !e.ControlPressed) return false; + bool runOperationFromHotkey(Func operation) + { + operationStarted(); + bool result = operation?.Invoke() ?? false; + operationEnded(); + + return result; + } + switch (e.Key) { case Key.G: - return CanReverse && OnReverse?.Invoke() == true; + return CanReverse && runOperationFromHotkey(OnReverse); case Key.H: - return CanScaleX && OnFlip?.Invoke(Direction.Horizontal) == true; + return CanScaleX && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Horizontal) ?? false); case Key.J: - return CanScaleY && OnFlip?.Invoke(Direction.Vertical) == true; + return CanScaleY && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Vertical) ?? false); } return base.OnKeyDown(e); @@ -135,6 +158,26 @@ namespace osu.Game.Screens.Edit.Compose.Components InternalChildren = new Drawable[] { + new Container + { + Name = "info text", + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = colours.YellowDark, + RelativeSizeAxes = Axes.Both, + }, + selectionDetailsText = new OsuSpriteText + { + Padding = new MarginPadding(2), + Colour = colours.Gray0, + Font = OsuFont.Default.With(size: 11), + Text = text, + } + } + }, new Container { Masking = true, @@ -152,7 +195,7 @@ namespace osu.Game.Screens.Edit.Compose.Components }, } }, - dragHandles = new Container + dragHandles = new SelectionBoxDragHandleContainer { RelativeSizeAxes = Axes.Both, // ensures that the centres of all drag handles line up with the middle of the selection box border. @@ -177,75 +220,76 @@ namespace osu.Game.Screens.Edit.Compose.Components private void addRotationComponents() { - const float separation = 40; - addButton(FontAwesome.Solid.Undo, "Rotate 90 degrees counter-clockwise", () => OnRotation?.Invoke(-90)); addButton(FontAwesome.Solid.Redo, "Rotate 90 degrees clockwise", () => OnRotation?.Invoke(90)); - AddRangeInternal(new Drawable[] - { - new Box - { - Depth = float.MaxValue, - Colour = colours.YellowLight, - Blending = BlendingParameters.Additive, - Alpha = 0.3f, - Size = new Vector2(BORDER_RADIUS, separation), - Anchor = Anchor.TopCentre, - Origin = Anchor.BottomCentre, - }, - new SelectionBoxDragHandleButton(FontAwesome.Solid.Redo, "Free rotate") - { - Anchor = Anchor.TopCentre, - Y = -separation, - HandleDrag = e => OnRotation?.Invoke(convertDragEventToAngleOfRotation(e)), - OperationStarted = operationStarted, - OperationEnded = operationEnded - } - }); + addRotateHandle(Anchor.TopLeft); + addRotateHandle(Anchor.TopRight); + addRotateHandle(Anchor.BottomLeft); + addRotateHandle(Anchor.BottomRight); } private void addYScaleComponents() { addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically (Ctrl-J)", () => OnFlip?.Invoke(Direction.Vertical)); - addDragHandle(Anchor.TopCentre); - addDragHandle(Anchor.BottomCentre); + addScaleHandle(Anchor.TopCentre); + addScaleHandle(Anchor.BottomCentre); } private void addFullScaleComponents() { - addDragHandle(Anchor.TopLeft); - addDragHandle(Anchor.TopRight); - addDragHandle(Anchor.BottomLeft); - addDragHandle(Anchor.BottomRight); + addScaleHandle(Anchor.TopLeft); + addScaleHandle(Anchor.TopRight); + addScaleHandle(Anchor.BottomLeft); + addScaleHandle(Anchor.BottomRight); } private void addXScaleComponents() { addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally (Ctrl-H)", () => OnFlip?.Invoke(Direction.Horizontal)); - addDragHandle(Anchor.CentreLeft); - addDragHandle(Anchor.CentreRight); + addScaleHandle(Anchor.CentreLeft); + addScaleHandle(Anchor.CentreRight); } private void addButton(IconUsage icon, string tooltip, Action action) { - buttons.Add(new SelectionBoxDragHandleButton(icon, tooltip) + var button = new SelectionBoxButton(icon, tooltip) { - OperationStarted = operationStarted, - OperationEnded = operationEnded, Action = action - }); + }; + + button.OperationStarted += operationStarted; + button.OperationEnded += operationEnded; + buttons.Add(button); } - private void addDragHandle(Anchor anchor) => dragHandles.Add(new SelectionBoxDragHandle + private void addScaleHandle(Anchor anchor) { - Anchor = anchor, - HandleDrag = e => OnScale?.Invoke(e.Delta, anchor), - OperationStarted = operationStarted, - OperationEnded = operationEnded - }); + var handle = new SelectionBoxScaleHandle + { + Anchor = anchor, + HandleDrag = e => OnScale?.Invoke(e.Delta, anchor) + }; + + handle.OperationStarted += operationStarted; + handle.OperationEnded += operationEnded; + dragHandles.AddScaleHandle(handle); + } + + private void addRotateHandle(Anchor anchor) + { + var handle = new SelectionBoxRotationHandle + { + Anchor = anchor, + HandleDrag = e => OnRotation?.Invoke(convertDragEventToAngleOfRotation(e)) + }; + + handle.OperationStarted += operationStarted; + handle.OperationEnded += operationEnded; + dragHandles.AddRotationHandle(handle); + } private int activeOperations; diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleButton.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs similarity index 73% rename from osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleButton.cs rename to osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs index 74ae949389..3b1dae6c3d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs @@ -12,10 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components { - /// - /// A drag "handle" which shares the visual appearance but behaves more like a clickable button. - /// - public sealed class SelectionBoxDragHandleButton : SelectionBoxDragHandle, IHasTooltip + public sealed class SelectionBoxButton : SelectionBoxControl, IHasTooltip { private SpriteIcon icon; @@ -23,7 +20,7 @@ namespace osu.Game.Screens.Edit.Compose.Components public Action Action; - public SelectionBoxDragHandleButton(IconUsage iconUsage, string tooltip) + public SelectionBoxButton(IconUsage iconUsage, string tooltip) { this.iconUsage = iconUsage; @@ -36,7 +33,7 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { - Size *= 2; + Size = new Vector2(20); AddInternal(icon = new SpriteIcon { RelativeSizeAxes = Axes.Both, @@ -49,16 +46,16 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override bool OnClick(ClickEvent e) { - OperationStarted?.Invoke(); + TriggerOperationStarted(); Action?.Invoke(); - OperationEnded?.Invoke(); + TriggerOperatoinEnded(); return true; } protected override void UpdateHoverState() { base.UpdateHoverState(); - icon.Colour = !HandlingMouse && IsHovered ? Color4.White : Color4.Black; + icon.FadeColour(!IsHeld && IsHovered ? Color4.White : Color4.Black, TRANSFORM_DURATION, Easing.OutQuint); } public string TooltipText { get; } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs new file mode 100644 index 0000000000..40d367bb80 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxControl.cs @@ -0,0 +1,97 @@ +// 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.Input.Events; +using osu.Game.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// Represents the base appearance for UI controls of the , + /// such as scale handles, rotation handles, buttons, etc... + /// + public abstract class SelectionBoxControl : CompositeDrawable + { + public const double TRANSFORM_DURATION = 100; + + public event Action OperationStarted; + public event Action OperationEnded; + + private Circle circle; + + /// + /// Whether the user is currently holding the control with mouse. + /// + public bool IsHeld { get; private set; } + + [Resolved] + protected OsuColour Colours { get; private set; } + + [BackgroundDependencyLoader] + private void load() + { + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + circle = new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + UpdateHoverState(); + FinishTransforms(true); + } + + protected override bool OnHover(HoverEvent e) + { + UpdateHoverState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + UpdateHoverState(); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + IsHeld = true; + UpdateHoverState(); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + IsHeld = false; + UpdateHoverState(); + } + + protected virtual void UpdateHoverState() + { + if (IsHeld) + circle.FadeColour(Colours.GrayF, TRANSFORM_DURATION, Easing.OutQuint); + else + circle.FadeColour(IsHovered ? Colours.Red : Colours.YellowDark, TRANSFORM_DURATION, Easing.OutQuint); + + this.ScaleTo(IsHeld || IsHovered ? 1.5f : 1, TRANSFORM_DURATION, Easing.OutQuint); + } + + protected void TriggerOperationStarted() => OperationStarted?.Invoke(); + + protected void TriggerOperatoinEnded() => OperationEnded?.Invoke(); + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs index 921b4eb042..65a95951cf 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs @@ -2,75 +2,17 @@ // 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.Input.Events; -using osu.Game.Graphics; -using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { - public class SelectionBoxDragHandle : Container + public abstract class SelectionBoxDragHandle : SelectionBoxControl { - public Action OperationStarted; - public Action OperationEnded; - public Action HandleDrag { get; set; } - private Circle circle; - - [Resolved] - private OsuColour colours { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - Size = new Vector2(10); - Origin = Anchor.Centre; - - InternalChildren = new Drawable[] - { - circle = new Circle - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - UpdateHoverState(); - } - - protected override bool OnHover(HoverEvent e) - { - UpdateHoverState(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - base.OnHoverLost(e); - UpdateHoverState(); - } - - protected bool HandlingMouse; - - protected override bool OnMouseDown(MouseDownEvent e) - { - HandlingMouse = true; - UpdateHoverState(); - return true; - } - protected override bool OnDragStart(DragStartEvent e) { - OperationStarted?.Invoke(); + TriggerOperationStarted(); return true; } @@ -82,24 +24,45 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override void OnDragEnd(DragEndEvent e) { - HandlingMouse = false; - OperationEnded?.Invoke(); + TriggerOperatoinEnded(); UpdateHoverState(); base.OnDragEnd(e); } - protected override void OnMouseUp(MouseUpEvent e) + #region Internal events for SelectionBoxDragHandleContainer + + internal event Action HoverGained; + internal event Action HoverLost; + internal event Action MouseDown; + internal event Action MouseUp; + + protected override bool OnHover(HoverEvent e) { - HandlingMouse = false; - UpdateHoverState(); - base.OnMouseUp(e); + bool result = base.OnHover(e); + HoverGained?.Invoke(); + return result; } - protected virtual void UpdateHoverState() + protected override void OnHoverLost(HoverLostEvent e) { - circle.Colour = HandlingMouse ? colours.GrayF : (IsHovered ? colours.Red : colours.YellowDark); - this.ScaleTo(HandlingMouse || IsHovered ? 1.5f : 1, 100, Easing.OutQuint); + base.OnHoverLost(e); + HoverLost?.Invoke(); } + + protected override bool OnMouseDown(MouseDownEvent e) + { + bool result = base.OnMouseDown(e); + MouseDown?.Invoke(); + return result; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + base.OnMouseUp(e); + MouseUp?.Invoke(); + } + + #endregion } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs new file mode 100644 index 0000000000..397158b9f6 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleContainer.cs @@ -0,0 +1,109 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// Represents a display composite containing and managing the visibility state of the selection box's drag handles. + /// + public class SelectionBoxDragHandleContainer : CompositeDrawable + { + private Container scaleHandles; + private Container rotationHandles; + + private readonly List allDragHandles = new List(); + + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + scaleHandles = new Container + { + RelativeSizeAxes = Axes.Both, + }, + rotationHandles = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(-12.5f), + }, + }; + } + + public void AddScaleHandle(SelectionBoxScaleHandle handle) + { + bindDragHandle(handle); + scaleHandles.Add(handle); + } + + public void AddRotationHandle(SelectionBoxRotationHandle handle) + { + handle.Alpha = 0; + handle.AlwaysPresent = true; + + bindDragHandle(handle); + rotationHandles.Add(handle); + } + + private void bindDragHandle(SelectionBoxDragHandle handle) + { + handle.HoverGained += updateRotationHandlesVisibility; + handle.HoverLost += updateRotationHandlesVisibility; + handle.MouseDown += updateRotationHandlesVisibility; + handle.MouseUp += updateRotationHandlesVisibility; + allDragHandles.Add(handle); + } + + private SelectionBoxRotationHandle displayedRotationHandle; + private SelectionBoxDragHandle activeHandle; + + private void updateRotationHandlesVisibility() + { + // if the active handle is a rotation handle and is held or hovered, + // then no need to perform any updates to the rotation handles visibility. + if (activeHandle is SelectionBoxRotationHandle && (activeHandle?.IsHeld == true || activeHandle?.IsHovered == true)) + return; + + displayedRotationHandle?.FadeOut(SelectionBoxControl.TRANSFORM_DURATION, Easing.OutQuint); + displayedRotationHandle = null; + + // if the active handle is not a rotation handle but is held, then keep the rotation handle hidden. + if (activeHandle?.IsHeld == true) + return; + + activeHandle = rotationHandles.FirstOrDefault(h => h.IsHeld || h.IsHovered); + activeHandle ??= allDragHandles.FirstOrDefault(h => h.IsHovered); + + if (activeHandle != null) + { + displayedRotationHandle = getCorrespondingRotationHandle(activeHandle, rotationHandles); + displayedRotationHandle?.FadeIn(SelectionBoxControl.TRANSFORM_DURATION, Easing.OutQuint); + } + } + + /// + /// Gets the rotation handle corresponding to the given handle. + /// + [CanBeNull] + private static SelectionBoxRotationHandle getCorrespondingRotationHandle(SelectionBoxDragHandle handle, IEnumerable rotationHandles) + { + if (handle is SelectionBoxRotationHandle rotationHandle) + return rotationHandle; + + return rotationHandles.SingleOrDefault(r => r.Anchor == handle.Anchor); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs new file mode 100644 index 0000000000..65a54292ab --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxRotationHandle.cs @@ -0,0 +1,42 @@ +// 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.EnumExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class SelectionBoxRotationHandle : SelectionBoxDragHandle + { + private SpriteIcon icon; + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(15f); + AddInternal(icon = new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Redo, + Scale = new Vector2 + { + X = Anchor.HasFlagFast(Anchor.x0) ? 1f : -1f, + Y = Anchor.HasFlagFast(Anchor.y0) ? 1f : -1f + } + }); + } + + protected override void UpdateHoverState() + { + base.UpdateHoverState(); + icon.FadeColour(!IsHeld && IsHovered ? Color4.White : Color4.Black, TRANSFORM_DURATION, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs new file mode 100644 index 0000000000..a87c661f45 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs @@ -0,0 +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 osu.Framework.Allocation; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class SelectionBoxScaleHandle : SelectionBoxDragHandle + { + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(10); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 788b485449..8939be925a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -4,92 +4,65 @@ using System; using System.Collections.Generic; using System.Linq; -using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Game.Audio; +using osu.Framework.Utils; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Objects.Types; using osuTK; using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { /// - /// A component which outlines s and handles movement of selections. + /// A component which outlines items and handles movement of selections. /// - public class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IHasContextMenu + public abstract class SelectionHandler : CompositeDrawable, IKeyBindingHandler, IHasContextMenu { - public IEnumerable SelectedBlueprints => selectedBlueprints; - private readonly List selectedBlueprints; + /// + /// The currently selected blueprints. + /// Should be used when operations are dealing directly with the visible blueprints. + /// For more general selection operations, use instead. + /// + public IReadOnlyList> SelectedBlueprints => selectedBlueprints; - public int SelectedCount => selectedBlueprints.Count; + /// + /// The currently selected items. + /// + public readonly BindableList SelectedItems = new BindableList(); - private Drawable content; - - private OsuSpriteText selectionDetailsText; + private readonly List> selectedBlueprints; protected SelectionBox SelectionBox { get; private set; } - [Resolved] - protected EditorBeatmap EditorBeatmap { get; private set; } - [Resolved(CanBeNull = true)] protected IEditorChangeHandler ChangeHandler { get; private set; } - public SelectionHandler() + protected SelectionHandler() { - selectedBlueprints = new List(); + selectedBlueprints = new List>(); RelativeSizeAxes = Axes.Both; AlwaysPresent = true; - Alpha = 0; } [BackgroundDependencyLoader] private void load(OsuColour colours) { - createStateBindables(); + InternalChild = SelectionBox = CreateSelectionBox(); - InternalChild = content = new Container + SelectedItems.CollectionChanged += (sender, args) => { - Children = new Drawable[] - { - // todo: should maybe be inside the SelectionBox? - new Container - { - Name = "info text", - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = colours.YellowDark, - RelativeSizeAxes = Axes.Both, - }, - selectionDetailsText = new OsuSpriteText - { - Padding = new MarginPadding(2), - Colour = colours.Gray0, - Font = OsuFont.Default.With(size: 11) - } - } - }, - SelectionBox = CreateSelectionBox(), - } + Scheduler.AddOnce(updateVisibility); }; } @@ -124,45 +97,44 @@ namespace osu.Game.Screens.Edit.Compose.Components #region User Input Handling /// - /// Handles the selected s being moved. + /// Handles the selected items being moved. /// /// - /// Just returning true is enough to allow updates to take place. + /// Just returning true is enough to allow default movement to take place. /// Custom implementation is only required if other attributes are to be considered, like changing columns. /// /// The move event. /// - /// Whether any s could be moved. - /// Returning true will also propagate StartTime changes provided by the closest . + /// Whether any items could be moved. /// - public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false; + public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false; /// - /// Handles the selected s being rotated. + /// Handles the selected items being rotated. /// /// The delta angle to apply to the selection. - /// Whether any s could be rotated. + /// Whether any items could be rotated. public virtual bool HandleRotation(float angle) => false; /// - /// Handles the selected s being scaled. + /// Handles the selected items being scaled. /// - /// The delta scale to apply, in playfield local coordinates. + /// The delta scale to apply, in local coordinates. /// The point of reference where the scale is originating from. - /// Whether any s could be scaled. + /// Whether any items could be scaled. public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false; /// - /// Handles the selected s being flipped. + /// Handles the selected items being flipped. /// /// The direction to flip - /// Whether any s could be flipped. + /// Whether any items could be flipped. public virtual bool HandleFlip(Direction direction) => false; /// - /// Handles the selected s being reversed pattern-wise. + /// Handles the selected items being reversed pattern-wise. /// - /// Whether any s could be reversed. + /// Whether any items could be reversed. public virtual bool HandleReverse() => false; public bool OnPressed(PlatformAction action) @@ -170,7 +142,7 @@ namespace osu.Game.Screens.Edit.Compose.Components switch (action.ActionMethod) { case PlatformActionMethod.Delete: - deleteSelected(); + DeleteSelected(); return true; } @@ -194,24 +166,23 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Handle a blueprint becoming selected. /// /// The blueprint. - internal void HandleSelected(SelectionBlueprint blueprint) + internal virtual void HandleSelected(SelectionBlueprint blueprint) { - selectedBlueprints.Add(blueprint); + // there are potentially multiple SelectionHandlers active, but we only want to add items to the selected list once. + if (!SelectedItems.Contains(blueprint.Item)) + SelectedItems.Add(blueprint.Item); - // there are potentially multiple SelectionHandlers active, but we only want to add hitobjects to the selected list once. - if (!EditorBeatmap.SelectedHitObjects.Contains(blueprint.HitObject)) - EditorBeatmap.SelectedHitObjects.Add(blueprint.HitObject); + selectedBlueprints.Add(blueprint); } /// /// Handle a blueprint becoming deselected. /// /// The blueprint. - internal void HandleDeselected(SelectionBlueprint blueprint) + internal virtual void HandleDeselected(SelectionBlueprint blueprint) { + SelectedItems.Remove(blueprint.Item); selectedBlueprints.Remove(blueprint); - - EditorBeatmap.SelectedHitObjects.Remove(blueprint.HitObject); } /// @@ -220,45 +191,87 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The blueprint. /// The mouse event responsible for selection. /// Whether a selection was performed. - internal bool HandleSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) + internal bool MouseDownSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { if (e.ShiftPressed && e.Button == MouseButton.Right) { handleQuickDeletion(blueprint); - return false; + return true; } - if (e.ControlPressed && e.Button == MouseButton.Left) + // while holding control, we only want to add to selection, not replace an existing selection. + if (e.ControlPressed && e.Button == MouseButton.Left && !blueprint.IsSelected) + { blueprint.ToggleSelection(); - else - ensureSelected(blueprint); + return true; + } - return true; + return ensureSelected(blueprint); } - private void handleQuickDeletion(SelectionBlueprint blueprint) + /// + /// Handle a blueprint requesting selection. + /// + /// The blueprint. + /// The mouse event responsible for deselection. + /// Whether a deselection was performed. + internal bool MouseUpSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) + { + if (blueprint.IsSelected) + { + blueprint.ToggleSelection(); + return true; + } + + return false; + } + + private void handleQuickDeletion(SelectionBlueprint blueprint) { if (blueprint.HandleQuickDeletion()) return; if (!blueprint.IsSelected) - EditorBeatmap.Remove(blueprint.HitObject); + DeleteItems(new[] { blueprint.Item }); else - deleteSelected(); + DeleteSelected(); } - private void ensureSelected(SelectionBlueprint blueprint) + /// + /// Given a selection target and a function of truth, retrieve the correct ternary state for display. + /// + protected static TernaryState GetStateFromSelection(IEnumerable selection, Func func) + { + if (selection.Any(func)) + return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate; + + return TernaryState.False; + } + + /// + /// Called whenever the deletion of items has been requested. + /// + /// The items to be deleted. + protected abstract void DeleteItems(IEnumerable items); + + /// + /// Ensure the blueprint is in a selected state. + /// + /// The blueprint to select. + /// Whether selection state was changed. + private bool ensureSelected(SelectionBlueprint blueprint) { if (blueprint.IsSelected) - return; + return false; DeselectAll?.Invoke(); blueprint.Select(); + return true; } - private void deleteSelected() + protected void DeleteSelected() { - EditorBeatmap.RemoveRange(selectedBlueprints.Select(b => b.HitObject)); + DeleteItems(selectedBlueprints.Select(b => b.Item)); } #endregion @@ -266,20 +279,20 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Outline Display /// - /// Updates whether this is visible. + /// Updates whether this is visible. /// private void updateVisibility() { - int count = selectedBlueprints.Count; + int count = SelectedItems.Count; - selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty; + SelectionBox.Text = count > 0 ? count.ToString() : string.Empty; + SelectionBox.FadeTo(count > 0 ? 1 : 0); - this.FadeTo(count > 0 ? 1 : 0); OnSelectionChanged(); } /// - /// Triggered whenever the set of selected objects changes. + /// Triggered whenever the set of selected items changes. /// Should update the selection box's state to match supported operations. /// protected virtual void OnSelectionChanged() @@ -293,172 +306,16 @@ namespace osu.Game.Screens.Edit.Compose.Components if (selectedBlueprints.Count == 0) return; - // Move the rectangle to cover the hitobjects - var topLeft = new Vector2(float.MaxValue, float.MaxValue); - var bottomRight = new Vector2(float.MinValue, float.MinValue); + // Move the rectangle to cover the items + RectangleF selectionRect = ToLocalSpace(selectedBlueprints[0].SelectionQuad).AABBFloat; - foreach (var blueprint in selectedBlueprints) - { - topLeft = Vector2.ComponentMin(topLeft, ToLocalSpace(blueprint.SelectionQuad.TopLeft)); - bottomRight = Vector2.ComponentMax(bottomRight, ToLocalSpace(blueprint.SelectionQuad.BottomRight)); - } + for (int i = 1; i < selectedBlueprints.Count; i++) + selectionRect = RectangleF.Union(selectionRect, ToLocalSpace(selectedBlueprints[i].SelectionQuad).AABBFloat); - topLeft -= new Vector2(5); - bottomRight += new Vector2(5); + selectionRect = selectionRect.Inflate(5f); - content.Size = bottomRight - topLeft; - content.Position = topLeft; - } - - #endregion - - #region Sample Changes - - /// - /// Adds a hit sample to all selected s. - /// - /// The name of the hit sample. - public void AddHitSample(string sampleName) - { - EditorBeatmap.BeginChange(); - - foreach (var h in EditorBeatmap.SelectedHitObjects) - { - // Make sure there isn't already an existing sample - if (h.Samples.Any(s => s.Name == sampleName)) - continue; - - h.Samples.Add(new HitSampleInfo(sampleName)); - } - - EditorBeatmap.EndChange(); - } - - /// - /// Set the new combo state of all selected s. - /// - /// Whether to set or unset. - /// Throws if any selected object doesn't implement - public void SetNewCombo(bool state) - { - EditorBeatmap.BeginChange(); - - foreach (var h in EditorBeatmap.SelectedHitObjects) - { - var comboInfo = h as IHasComboInformation; - - if (comboInfo == null || comboInfo.NewCombo == state) continue; - - comboInfo.NewCombo = state; - EditorBeatmap.Update(h); - } - - EditorBeatmap.EndChange(); - } - - /// - /// Removes a hit sample from all selected s. - /// - /// The name of the hit sample. - public void RemoveHitSample(string sampleName) - { - EditorBeatmap.BeginChange(); - - foreach (var h in EditorBeatmap.SelectedHitObjects) - h.SamplesBindable.RemoveAll(s => s.Name == sampleName); - - EditorBeatmap.EndChange(); - } - - #endregion - - #region Selection State - - /// - /// The state of "new combo" for all selected hitobjects. - /// - public readonly Bindable SelectionNewComboState = new Bindable(); - - /// - /// The state of each sample type for all selected hitobjects. Keys match with constant specifications. - /// - public readonly Dictionary> SelectionSampleStates = new Dictionary>(); - - /// - /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) - /// - private void createStateBindables() - { - foreach (var sampleName in HitSampleInfo.AllAdditions) - { - var bindable = new Bindable - { - Description = sampleName.Replace("hit", string.Empty).Titleize() - }; - - bindable.ValueChanged += state => - { - switch (state.NewValue) - { - case TernaryState.False: - RemoveHitSample(sampleName); - break; - - case TernaryState.True: - AddHitSample(sampleName); - break; - } - }; - - SelectionSampleStates[sampleName] = bindable; - } - - // new combo - SelectionNewComboState.ValueChanged += state => - { - switch (state.NewValue) - { - case TernaryState.False: - SetNewCombo(false); - break; - - case TernaryState.True: - SetNewCombo(true); - break; - } - }; - - // bring in updates from selection changes - EditorBeatmap.HitObjectUpdated += _ => Scheduler.AddOnce(UpdateTernaryStates); - EditorBeatmap.SelectedHitObjects.CollectionChanged += (sender, args) => - { - Scheduler.AddOnce(updateVisibility); - Scheduler.AddOnce(UpdateTernaryStates); - }; - } - - /// - /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated). - /// - protected virtual void UpdateTernaryStates() - { - SelectionNewComboState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType(), h => h.NewCombo); - - foreach (var (sampleName, bindable) in SelectionSampleStates) - { - bindable.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects, h => h.Samples.Any(s => s.Name == sampleName)); - } - } - - /// - /// Given a selection target and a function of truth, retrieve the correct ternary state for display. - /// - protected TernaryState GetStateFromSelection(IEnumerable selection, Func func) - { - if (selection.Any(func)) - return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate; - - return TernaryState.False; + SelectionBox.Position = selectionRect.Location; + SelectionBox.Size = selectionRect.Size; } #endregion @@ -469,30 +326,17 @@ namespace osu.Game.Screens.Edit.Compose.Components { get { - if (!selectedBlueprints.Any(b => b.IsHovered)) + if (!SelectedBlueprints.Any(b => b.IsHovered)) return Array.Empty(); var items = new List(); - items.AddRange(GetContextMenuItemsForSelection(selectedBlueprints)); + items.AddRange(GetContextMenuItemsForSelection(SelectedBlueprints)); - if (selectedBlueprints.All(b => b.HitObject is IHasComboInformation)) - { - items.Add(new TernaryStateMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }); - } + if (SelectedBlueprints.Count == 1) + items.AddRange(SelectedBlueprints[0].ContextMenuItems); - if (selectedBlueprints.Count == 1) - items.AddRange(selectedBlueprints[0].ContextMenuItems); - - items.AddRange(new[] - { - new OsuMenuItem("Sound") - { - Items = SelectionSampleStates.Select(kvp => - new TernaryStateMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() - }, - new OsuMenuItem("Delete", MenuItemType.Destructive, deleteSelected), - }); + items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, DeleteSelected)); return items.ToArray(); } @@ -503,9 +347,102 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// The current selection. /// The relevant menu items. - protected virtual IEnumerable GetContextMenuItemsForSelection(IEnumerable selection) + protected virtual IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) => Enumerable.Empty(); #endregion + + #region Helper Methods + + /// + /// Rotate a point around an arbitrary origin. + /// + /// The point. + /// The centre origin to rotate around. + /// The angle to rotate (in degrees). + protected static Vector2 RotatePointAroundOrigin(Vector2 point, Vector2 origin, float angle) + { + angle = -angle; + + point.X -= origin.X; + point.Y -= origin.Y; + + Vector2 ret; + ret.X = point.X * MathF.Cos(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Sin(MathUtils.DegreesToRadians(angle)); + ret.Y = point.X * -MathF.Sin(MathUtils.DegreesToRadians(angle)) + point.Y * MathF.Cos(MathUtils.DegreesToRadians(angle)); + + ret.X += origin.X; + ret.Y += origin.Y; + + return ret; + } + + /// + /// Given a flip direction, a surrounding quad for all selected objects, and a position, + /// will return the flipped position in screen space coordinates. + /// + protected static Vector2 GetFlippedPosition(Direction direction, Quad quad, Vector2 position) + { + var centre = quad.Centre; + + switch (direction) + { + case Direction.Horizontal: + position.X = centre.X - (position.X - centre.X); + break; + + case Direction.Vertical: + position.Y = centre.Y - (position.Y - centre.Y); + break; + } + + return position; + } + + /// + /// Given a scale vector, a surrounding quad for all selected objects, and a position, + /// will return the scaled position in screen space coordinates. + /// + protected static Vector2 GetScaledPosition(Anchor reference, Vector2 scale, Quad selectionQuad, Vector2 position) + { + // adjust the direction of scale depending on which side the user is dragging. + float xOffset = ((reference & Anchor.x0) > 0) ? -scale.X : 0; + float yOffset = ((reference & Anchor.y0) > 0) ? -scale.Y : 0; + + // guard against no-ops and NaN. + if (scale.X != 0 && selectionQuad.Width > 0) + position.X = selectionQuad.TopLeft.X + xOffset + (position.X - selectionQuad.TopLeft.X) / selectionQuad.Width * (selectionQuad.Width + scale.X); + + if (scale.Y != 0 && selectionQuad.Height > 0) + position.Y = selectionQuad.TopLeft.Y + yOffset + (position.Y - selectionQuad.TopLeft.Y) / selectionQuad.Height * (selectionQuad.Height + scale.Y); + + return position; + } + + /// + /// Returns a quad surrounding the provided points. + /// + /// The points to calculate a quad for. + protected static Quad GetSurroundingQuad(IEnumerable points) + { + if (!points.Any()) + return new Quad(); + + Vector2 minPosition = new Vector2(float.MaxValue, float.MaxValue); + Vector2 maxPosition = new Vector2(float.MinValue, float.MinValue); + + // Go through all hitobjects to make sure they would remain in the bounds of the editor after movement, before any movement is attempted + foreach (var p in points) + { + minPosition = Vector2.ComponentMin(minPosition, p); + maxPosition = Vector2.ComponentMax(maxPosition, p); + } + + Vector2 size = maxPosition - minPosition; + + return new Quad(minPosition.X, minPosition.Y, size.X, size.Y); + } + + #endregion } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index 510ba8c094..3248936765 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -1,67 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class DifficultyPointPiece : CompositeDrawable + public class DifficultyPointPiece : TopPointPiece { - private readonly DifficultyControlPoint difficultyPoint; - - private OsuSpriteText speedMultiplierText; private readonly BindableNumber speedMultiplier; - public DifficultyPointPiece(DifficultyControlPoint difficultyPoint) + public DifficultyPointPiece(DifficultyControlPoint point) + : base(point) { - this.difficultyPoint = difficultyPoint; - speedMultiplier = difficultyPoint.SpeedMultiplierBindable.GetBoundCopy(); + speedMultiplier = point.SpeedMultiplierBindable.GetBoundCopy(); + + Y = Height; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + protected override void LoadComplete() { - RelativeSizeAxes = Axes.Y; - AutoSizeAxes = Axes.X; - - Color4 colour = difficultyPoint.GetRepresentingColour(colours); - - InternalChildren = new Drawable[] - { - new Box - { - Colour = colour, - Width = 2, - RelativeSizeAxes = Axes.Y, - }, - new Container - { - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = colour, - RelativeSizeAxes = Axes.Both, - }, - speedMultiplierText = new OsuSpriteText - { - Font = OsuFont.Default.With(weight: FontWeight.Bold), - Colour = Color4.White, - } - } - }, - }; - - speedMultiplier.BindValueChanged(multiplier => speedMultiplierText.Text = $"{multiplier.NewValue:n2}x", true); + base.LoadComplete(); + speedMultiplier.BindValueChanged(multiplier => Label.Text = $"{multiplier.NewValue:n2}x", true); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index 0f11fb1126..9461f5e885 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -3,9 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; @@ -23,7 +21,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly BindableNumber volume; private OsuSpriteText text; - private Box volumeBox; + private Container volumeBox; + + private const int max_volume_height = 22; public SamplePointPiece(SampleControlPoint samplePoint) { @@ -35,8 +35,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [BackgroundDependencyLoader] private void load(OsuColour colours) { - Origin = Anchor.TopLeft; - Anchor = Anchor.TopLeft; + Margin = new MarginPadding { Vertical = 5 }; + + Origin = Anchor.BottomCentre; + Anchor = Anchor.BottomCentre; AutoSizeAxes = Axes.X; RelativeSizeAxes = Axes.Y; @@ -45,40 +47,43 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline InternalChildren = new Drawable[] { + volumeBox = new Circle + { + CornerRadius = 5, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Y = -20, + Width = 10, + Colour = colour, + }, new Container { - RelativeSizeAxes = Axes.Y, - Width = 20, + AutoSizeAxes = Axes.X, + Height = 16, + Masking = true, + CornerRadius = 8, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, Children = new Drawable[] { - volumeBox = new Box - { - X = 2, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Colour = ColourInfo.GradientVertical(colour, Color4.Black), - RelativeSizeAxes = Axes.Both, - }, new Box { - Colour = colour.Lighten(0.2f), - Width = 2, - RelativeSizeAxes = Axes.Y, + Colour = colour, + RelativeSizeAxes = Axes.Both, }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(5), + Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold), + Colour = colours.B5, + } } }, - text = new OsuSpriteText - { - X = 2, - Y = -5, - Anchor = Anchor.BottomLeft, - Alpha = 0.9f, - Rotation = -90, - Font = OsuFont.Default.With(weight: FontWeight.SemiBold) - } }; - volume.BindValueChanged(volume => volumeBox.Height = volume.NewValue / 100f, true); + volume.BindValueChanged(volume => volumeBox.Height = max_volume_height * volume.NewValue / 100f, true); bank.BindValueChanged(bank => text.Text = bank.NewValue, true); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 666026e05e..621a24c67d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -9,6 +9,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -22,6 +23,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Cached] public class Timeline : ZoomableScrollContainer, IPositionSnapProvider { + private readonly Drawable userContent; public readonly Bindable WaveformVisible = new Bindable(); public readonly Bindable ControlPointsVisible = new Bindable(); @@ -55,8 +57,16 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private Track track; - public Timeline() + private const float timeline_height = 72; + private const float timeline_expanded_height = 156; + + public Timeline(Drawable userContent) { + this.userContent = userContent; + + RelativeSizeAxes = Axes.X; + Height = timeline_height; + ZoomDuration = 200; ZoomEasing = Easing.OutQuint; ScrollbarVisible = false; @@ -68,18 +78,31 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private TimelineControlPointDisplay controlPoints; + private Container mainContent; + private Bindable waveformOpacity; [BackgroundDependencyLoader] private void load(IBindable beatmap, OsuColour colours, OsuConfigManager config) { + CentreMarker centreMarker; + + // We don't want the centre marker to scroll + AddInternal(centreMarker = new CentreMarker()); + AddRange(new Drawable[] { - new Container + controlPoints = new TimelineControlPointDisplay { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + Height = timeline_expanded_height, + }, + mainContent = new Container + { + RelativeSizeAxes = Axes.X, + Height = timeline_height, Depth = float.MaxValue, - Children = new Drawable[] + Children = new[] { waveform = new WaveformGraph { @@ -89,21 +112,22 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline MidColour = colours.BlueDark, HighColour = colours.BlueDarker, }, + centreMarker.CreateProxy(), ticks = new TimelineTickDisplay(), - controlPoints = new TimelineControlPointDisplay(), + new Box + { + Name = "zero marker", + RelativeSizeAxes = Axes.Y, + Width = 2, + Origin = Anchor.TopCentre, + Colour = colours.YellowDarker, + }, + userContent, } }, }); - // We don't want the centre marker to scroll - AddInternal(new CentreMarker { Depth = float.MaxValue }); - waveformOpacity = config.GetBindable(OsuSetting.EditorWaveformOpacity); - waveformOpacity.BindValueChanged(_ => updateWaveformOpacity(), true); - - WaveformVisible.ValueChanged += _ => updateWaveformOpacity(); - ControlPointsVisible.ValueChanged += visible => controlPoints.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint); - TicksVisible.ValueChanged += visible => ticks.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint); Beatmap.BindTo(beatmap); Beatmap.BindValueChanged(b => @@ -121,6 +145,35 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }, true); } + protected override void LoadComplete() + { + base.LoadComplete(); + + waveformOpacity.BindValueChanged(_ => updateWaveformOpacity(), true); + + WaveformVisible.ValueChanged += _ => updateWaveformOpacity(); + TicksVisible.ValueChanged += visible => ticks.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint); + ControlPointsVisible.BindValueChanged(visible => + { + if (visible.NewValue) + { + this.ResizeHeightTo(timeline_expanded_height, 200, Easing.OutQuint); + mainContent.MoveToY(36, 200, Easing.OutQuint); + + // delay the fade in else masking looks weird. + controlPoints.Delay(180).FadeIn(400, Easing.OutQuint); + } + else + { + controlPoints.FadeOut(200, Easing.OutQuint); + + // likewise, delay the resize until the fade is complete. + this.Delay(180).ResizeHeightTo(timeline_height, 200, Easing.OutQuint); + mainContent.Delay(180).MoveToY(0, 200, Easing.OutQuint); + } + }, true); + } + private void updateWaveformOpacity() => waveform.FadeTo(WaveformVisible.Value ? waveformOpacity.Value : 0, 200, Easing.OutQuint); @@ -177,7 +230,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (!track.IsLoaded) return; - editorClock.Seek(Current / Content.DrawWidth * track.Length); + double target = Current / Content.DrawWidth * track.Length; + editorClock.Seek(Math.Min(track.Length, target)); } private void scrollToTrackTime() diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs index 0ec48e04c6..1541ceade5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs @@ -12,11 +12,19 @@ using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineArea : Container + public class TimelineArea : CompositeDrawable { - public readonly Timeline Timeline = new Timeline { RelativeSizeAxes = Axes.Both }; + public Timeline Timeline; - protected override Container Content => Timeline; + private readonly Drawable userContent; + + public TimelineArea(Drawable content = null) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + userContent = content ?? Drawable.Empty(); + } [BackgroundDependencyLoader] private void load() @@ -37,7 +45,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }, new GridContainer { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Content = new[] { new Drawable[] @@ -55,11 +64,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }, new FillFlowContainer { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, AutoSizeAxes = Axes.Y, Width = 160, - Padding = new MarginPadding { Horizontal = 10 }, + Padding = new MarginPadding(10), Direction = FillDirection.Vertical, Spacing = new Vector2(0, 4), Children = new[] @@ -123,14 +130,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } }, - Timeline + Timeline = new Timeline(userContent), }, }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize), new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Distributed), + new Dimension(), } } }; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 1fc529910b..6f04f36b83 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -2,38 +2,50 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Graphics; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; +using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - internal class TimelineBlueprintContainer : BlueprintContainer + internal class TimelineBlueprintContainer : EditorBlueprintContainer { [Resolved(CanBeNull = true)] private Timeline timeline { get; set; } - [Resolved] - private EditorBeatmap beatmap { get; set; } - [Resolved] private OsuColour colours { get; set; } private DragEvent lastDragEvent; private Bindable placement; - private SelectionBlueprint placementBlueprint; + private SelectionBlueprint placementBlueprint; - private readonly Box backgroundBox; + private SelectableAreaBackground backgroundBox; + + // we only care about checking vertical validity. + // this allows selecting and dragging selections before time=0. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + float localY = ToLocalSpace(screenSpacePos).Y; + return DrawRectangle.Top <= localY && DrawRectangle.Bottom >= localY; + } public TimelineBlueprintContainer(HitObjectComposer composer) : base(composer) @@ -43,12 +55,16 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Origin = Anchor.Centre; Height = 0.6f; + } - AddInternal(backgroundBox = new Box + [BackgroundDependencyLoader] + private void load() + { + AddInternal(backgroundBox = new SelectableAreaBackground { Colour = Color4.Black, - RelativeSizeAxes = Axes.Both, - Alpha = 0.1f, + Depth = float.MaxValue, + Blending = BlendingParameters.Additive, }); } @@ -57,7 +73,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline base.LoadComplete(); DragBox.Alpha = 0; - placement = beatmap.PlacementObject.GetBoundCopy(); + placement = Beatmap.PlacementObject.GetBoundCopy(); placement.ValueChanged += placementChanged; } @@ -81,7 +97,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected override Container CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; + protected override Container> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; protected override bool OnHover(HoverEvent e) { @@ -121,14 +137,55 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } base.Update(); + + updateStacking(); } - protected override SelectionHandler CreateSelectionHandler() => new TimelineSelectionHandler(); - - protected override SelectionBlueprint CreateBlueprintFor(HitObject hitObject) => new TimelineHitObjectBlueprint(hitObject) + private void updateStacking() { - OnDragHandled = handleScrollViaDrag - }; + // because only blueprints of objects which are alive (via pooling) are displayed in the timeline, it's feasible to do this every-update. + + const int stack_offset = 5; + + // after the stack gets this tall, we can presume there is space underneath to draw subsequent blueprints. + const int stack_reset_count = 3; + + Stack currentConcurrentObjects = new Stack(); + + foreach (var b in SelectionBlueprints.Reverse()) + { + // remove objects from the stack as long as their end time is in the past. + while (currentConcurrentObjects.TryPeek(out HitObject hitObject)) + { + if (Precision.AlmostBigger(hitObject.GetEndTime(), b.Item.StartTime, 1)) + break; + + currentConcurrentObjects.Pop(); + } + + // if the stack gets too high, we should have space below it to display the next batch of objects. + // importantly, we only do this if time has incremented, else a stack of hitobjects all at the same time value would start to overlap themselves. + if (currentConcurrentObjects.TryPeek(out HitObject h) && !Precision.AlmostEquals(h.StartTime, b.Item.StartTime, 1)) + { + if (currentConcurrentObjects.Count >= stack_reset_count) + currentConcurrentObjects.Clear(); + } + + b.Y = -(stack_offset * currentConcurrentObjects.Count); + + currentConcurrentObjects.Push(b.Item); + } + } + + protected override SelectionHandler CreateSelectionHandler() => new TimelineSelectionHandler(); + + protected override SelectionBlueprint CreateBlueprintFor(HitObject item) + { + return new TimelineHitObjectBlueprint(item) + { + OnDragHandled = handleScrollViaDrag + }; + } protected override DragBox CreateDragBox(Action performSelect) => new TimelineDragBox(performSelect); @@ -152,10 +209,79 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - internal class TimelineSelectionHandler : SelectionHandler + private class SelectableAreaBackground : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + Alpha = 0.1f; + + AddRangeInternal(new[] + { + // fade out over intro time, outside the valid time bounds. + new Box + { + RelativeSizeAxes = Axes.Y, + Width = 200, + Origin = Anchor.TopRight, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0), Color4.White), + }, + new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + } + }); + } + } + + internal class TimelineSelectionHandler : EditorSelectionHandler, IKeyBindingHandler { // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation - public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.EditorNudgeLeft: + nudgeSelection(-1); + return true; + + case GlobalAction.EditorNudgeRight: + nudgeSelection(1); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } + + /// + /// Nudge the current selection by the specified multiple of beat divisor lengths, + /// based on the timing at the first object in the selection. + /// + /// The direction and count of beat divisor lengths to adjust. + private void nudgeSelection(int amount) + { + var selected = EditorBeatmap.SelectedHitObjects; + + if (selected.Count == 0) + return; + + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(selected.First().StartTime); + double adjustment = timingPoint.BeatLength / EditorBeatmap.BeatDivisor * amount; + + EditorBeatmap.PerformOnSelection(h => + { + h.StartTime += adjustment; + EditorBeatmap.Update(h); + }); + } } private class TimelineDragBox : DragBox @@ -203,7 +329,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Box.X = Math.Min(rescaledStart, rescaledEnd); Box.Width = Math.Abs(rescaledStart - rescaledEnd); - PerformSelection?.Invoke(Box.ScreenSpaceDrawQuad.AABBFloat); + var boxScreenRect = Box.ScreenSpaceDrawQuad.AABBFloat; + + // we don't care about where the hitobjects are vertically. in cases like stacking display, they may be outside the box without this adjustment. + boxScreenRect.Y -= boxScreenRect.Height; + boxScreenRect.Height *= 2; + + PerformSelection?.Invoke(boxScreenRect); } public override void Hide() @@ -213,13 +345,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - protected class TimelineSelectionBlueprintContainer : Container + protected class TimelineSelectionBlueprintContainer : Container> { - protected override Container Content { get; } + protected override Container> Content { get; } public TimelineSelectionBlueprintContainer() { - AddInternal(new TimelinePart(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); + AddInternal(new TimelinePart>(Content = new HitObjectOrderedSelectionContainer { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs index 18600bcdee..8520567fa9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs @@ -4,7 +4,6 @@ using System.Collections.Specialized; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -17,11 +16,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { private readonly IBindableList controlPointGroups = new BindableList(); - public TimelineControlPointDisplay() - { - RelativeSizeAxes = Axes.Both; - } - protected override void LoadBeatmap(EditorBeatmap beatmap) { base.LoadBeatmap(beatmap); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs index fb69f16792..c4beb40f92 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs @@ -27,6 +27,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline RelativeSizeAxes = Axes.Y; AutoSizeAxes = Axes.X; + Origin = Anchor.TopCentre; + X = (float)group.Time; } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index d24614299c..dbe689be2f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -16,7 +17,6 @@ using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -26,11 +26,11 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineHitObjectBlueprint : SelectionBlueprint + public class TimelineHitObjectBlueprint : SelectionBlueprint { - private const float thickness = 5; - private const float shadow_radius = 5; - private const float circle_size = 34; + private const float circle_size = 38; + + private Container repeatsContainer; public Action OnDragHandled; @@ -40,117 +40,75 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private Bindable indexInCurrentComboBindable; private Bindable comboIndexBindable; - private readonly Circle circle; - private readonly DragBar dragBar; - private readonly List shadowComponents = new List(); - private readonly Container mainComponents; + private readonly ExtendableCircle circle; + private readonly Border border; + + private readonly Container colouredComponents; private readonly OsuSpriteText comboIndexText; [Resolved] private ISkinSource skin { get; set; } - public TimelineHitObjectBlueprint(HitObject hitObject) - : base(hitObject) + public TimelineHitObjectBlueprint(HitObject item) + : base(item) { Anchor = Anchor.CentreLeft; Origin = Anchor.CentreLeft; - startTime = hitObject.StartTimeBindable.GetBoundCopy(); + startTime = item.StartTimeBindable.GetBoundCopy(); startTime.BindValueChanged(time => X = (float)time.NewValue, true); RelativePositionAxes = Axes.X; RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; + Height = circle_size; AddRangeInternal(new Drawable[] { - mainComponents = new Container + circle = new ExtendableCircle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + border = new Border + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + colouredComponents = new Container { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - comboIndexText = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.Centre, - Font = OsuFont.Numeric.With(size: circle_size / 2, weight: FontWeight.Black), + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + comboIndexText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + Y = -1, + Font = OsuFont.Default.With(size: circle_size * 0.5f, weight: FontWeight.Regular), + }, + } }, }); - circle = new Circle + if (item is IHasDuration) { - Size = new Vector2(circle_size), - Anchor = Anchor.CentreLeft, - Origin = Anchor.Centre, - EdgeEffect = new EdgeEffectParameters + colouredComponents.Add(new DragArea(item) { - Type = EdgeEffectType.Shadow, - Radius = shadow_radius, - Colour = Color4.Black - }, - }; - - shadowComponents.Add(circle); - - if (hitObject is IHasDuration) - { - DragBar dragBarUnderlay; - Container extensionBar; - - mainComponents.AddRange(new Drawable[] - { - extensionBar = new Container - { - Masking = true, - Size = new Vector2(1, thickness), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativePositionAxes = Axes.X, - RelativeSizeAxes = Axes.X, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = shadow_radius, - Colour = Color4.Black - }, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - } - }, - circle, - // only used for drawing the shadow - dragBarUnderlay = new DragBar(null), - // cover up the shadow on the join - new Box - { - Height = thickness, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.X, - }, - dragBar = new DragBar(hitObject) { OnDragHandled = e => OnDragHandled?.Invoke(e) }, + OnDragHandled = e => OnDragHandled?.Invoke(e) }); - - shadowComponents.Add(dragBarUnderlay); - shadowComponents.Add(extensionBar); } - else - { - mainComponents.Add(circle); - } - - updateShadows(); } protected override void LoadComplete() { base.LoadComplete(); - if (HitObject is IHasComboInformation comboInfo) + if (Item is IHasComboInformation comboInfo) { indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); @@ -162,26 +120,45 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } + protected override void OnSelected() + { + // base logic hides selected blueprints when not selected, but timeline doesn't do that. + updateComboColour(); + } + + protected override void OnDeselected() + { + // base logic hides selected blueprints when not selected, but timeline doesn't do that. + updateComboColour(); + } + private void updateComboIndex() => comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString(); private void updateComboColour() { - if (!(HitObject is IHasComboInformation combo)) + if (!(Item is IHasComboInformation combo)) return; var comboColours = skin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); var comboColour = combo.GetComboColour(comboColours); - if (HitObject is IHasDuration) - mainComponents.Colour = ColourInfo.GradientHorizontal(comboColour, Color4.White); + if (IsSelected) + { + border.Show(); + comboColour = comboColour.Lighten(0.3f); + } else - mainComponents.Colour = comboColour; + { + border.Hide(); + } - var col = mainComponents.Colour.TopLeft.Linear; - float brightness = col.R + col.G + col.B; + if (Item is IHasDuration duration && duration.Duration > 0) + circle.Colour = ColourInfo.GradientHorizontal(comboColour, comboColour.Lighten(0.4f)); + else + circle.Colour = comboColour; - // decide the combo index colour based on brightness? - comboIndexText.Colour = brightness > 0.5f ? Color4.Black : Color4.White; + var col = circle.Colour.TopLeft.Linear; + colouredComponents.Colour = OsuColour.ForegroundTextColourFor(col); } protected override void Update() @@ -189,25 +166,23 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline base.Update(); // no bindable so we perform this every update - float duration = (float)(HitObject.GetEndTime() - HitObject.StartTime); + float duration = (float)(Item.GetEndTime() - Item.StartTime); if (Width != duration) { Width = duration; // kind of haphazard but yeah, no bindables. - if (HitObject is IHasRepeats repeats) + if (Item is IHasRepeats repeats) updateRepeats(repeats); } } - private Container repeatsContainer; - private void updateRepeats(IHasRepeats repeats) { repeatsContainer?.Expire(); - mainComponents.Add(repeatsContainer = new Container + colouredComponents.Add(repeatsContainer = new Container { RelativeSizeAxes = Axes.Both, }); @@ -216,7 +191,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { repeatsContainer.Add(new Circle { - Size = new Vector2(circle_size / 2), + Size = new Vector2(circle_size / 3), + Alpha = 0.2f, Anchor = Anchor.CentreLeft, Origin = Anchor.Centre, RelativePositionAxes = Axes.X, @@ -228,61 +204,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override bool ShouldBeConsideredForInput(Drawable child) => true; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => - base.ReceivePositionalInputAt(screenSpacePos) || - circle.ReceivePositionalInputAt(screenSpacePos) || - dragBar?.ReceivePositionalInputAt(screenSpacePos) == true; + circle.ReceivePositionalInputAt(screenSpacePos); - protected override void OnSelected() - { - updateShadows(); - } - - private void updateShadows() - { - foreach (var s in shadowComponents) - { - if (State == SelectionState.Selected) - { - s.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = shadow_radius / 2, - Colour = Color4.Orange, - }; - } - else - { - s.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = shadow_radius, - Colour = State == SelectionState.Selected ? Color4.Orange : Color4.Black - }; - } - } - } - - protected override void OnDeselected() - { - updateShadows(); - } - - public override Quad SelectionQuad - { - get - { - // correctly include the circle in the selection quad region, as it is usually outside the blueprint itself. - var leftQuad = circle.ScreenSpaceDrawQuad; - var rightQuad = dragBar?.ScreenSpaceDrawQuad ?? ScreenSpaceDrawQuad; - - return new Quad(leftQuad.TopLeft, Vector2.ComponentMax(rightQuad.TopRight, leftQuad.TopRight), - leftQuad.BottomLeft, Vector2.ComponentMax(rightQuad.BottomRight, leftQuad.BottomRight)); - } - } + public override Quad SelectionQuad => circle.ScreenSpaceDrawQuad; public override Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft; - public class DragBar : Container + public class DragArea : Circle { private readonly HitObject hitObject; @@ -293,13 +221,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public override bool HandlePositionalInput => hitObject != null; - public DragBar(HitObject hitObject) + public DragArea(HitObject hitObject) { this.hitObject = hitObject; - CornerRadius = 2; + CornerRadius = circle_size / 2; Masking = true; - Size = new Vector2(5, 1); + Size = new Vector2(circle_size, 1); Anchor = Anchor.CentreRight; Origin = Anchor.Centre; RelativePositionAxes = Axes.X; @@ -314,6 +242,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + updateState(); + FinishTransforms(); + } + protected override bool OnHover(HoverEvent e) { updateState(); @@ -345,7 +281,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void updateState() { - Colour = IsHovered || hasMouseDown ? Color4.OrangeRed : Color4.White; + if (hasMouseDown) + { + this.ScaleTo(0.7f, 200, Easing.OutQuint); + } + else if (IsHovered) + { + this.ScaleTo(0.8f, 200, Easing.OutQuint); + } + else + { + this.ScaleTo(0.6f, 200, Easing.OutQuint); + } + + this.FadeTo(IsHovered || hasMouseDown ? 0.8f : 0.2f, 200, Easing.OutQuint); } [Resolved] @@ -406,5 +355,48 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline changeHandler?.EndChange(); } } + + public class Border : ExtendableCircle + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Content.Child.Alpha = 0; + Content.Child.AlwaysPresent = true; + + Content.BorderColour = colours.Yellow; + Content.EdgeEffect = new EdgeEffectParameters(); + } + } + + /// + /// A circle with externalised end caps so it can take up the full width of a relative width area. + /// + public class ExtendableCircle : CompositeDrawable + { + protected readonly Circle Content; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Content.ReceivePositionalInputAt(screenSpacePos); + + public override Quad ScreenSpaceDrawQuad => Content.ScreenSpaceDrawQuad; + + public ExtendableCircle() + { + Padding = new MarginPadding { Horizontal = -circle_size / 2f }; + InternalChild = Content = new Circle + { + BorderColour = OsuColour.Gray(0.75f), + BorderThickness = 4, + Masking = true, + RelativeSizeAxes = Axes.Both, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 5, + Colour = Color4.Black.Opacity(0.4f) + } + }; + } + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs index fb11b859a7..3aaf0451c8 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs @@ -31,6 +31,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved] private OsuColour colours { get; set; } + private static readonly int highest_divisor = BindableBeatDivisor.VALID_DIVISORS.Last(); + public TimelineTickDisplay() { RelativeSizeAxes = Axes.Both; @@ -78,8 +80,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (timeline != null) { var newRange = ( - (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X, - (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X); + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X, + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.MAX_WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X); if (visibleRange != newRange) { @@ -98,7 +100,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void createTicks() { int drawableIndex = 0; - int highestDivisor = BindableBeatDivisor.VALID_DIVISORS.Last(); nextMinTick = null; nextMaxTick = null; @@ -124,27 +125,18 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (beat == 0 && i == 0) nextMinTick = float.MinValue; - var indexInBar = beat % ((int)point.TimeSignature * beatDivisor.Value); + int indexInBar = beat % ((int)point.TimeSignature * beatDivisor.Value); var divisor = BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value); var colour = BindableBeatDivisor.GetColourFor(divisor, colours); // even though "bar lines" take up the full vertical space, we render them in two pieces because it allows for less anchor/origin churn. - var height = indexInBar == 0 ? 0.5f : 0.1f - (float)divisor / highestDivisor * 0.08f; - var topPoint = getNextUsablePoint(); - topPoint.X = xPos; - topPoint.Colour = colour; - topPoint.Height = height; - topPoint.Anchor = Anchor.TopLeft; - topPoint.Origin = Anchor.TopCentre; - - var bottomPoint = getNextUsablePoint(); - bottomPoint.X = xPos; - bottomPoint.Colour = colour; - bottomPoint.Anchor = Anchor.BottomLeft; - bottomPoint.Origin = Anchor.BottomCentre; - bottomPoint.Height = height; + var line = getNextUsableLine(); + line.X = xPos; + line.Width = PointVisualisation.MAX_WIDTH * getWidth(indexInBar, divisor); + line.Height = 0.9f * getHeight(indexInBar, divisor); + line.Colour = colour; } beat++; @@ -163,7 +155,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline tickCache.Validate(); - Drawable getNextUsablePoint() + Drawable getNextUsableLine() { PointVisualisation point; if (drawableIndex >= Count) @@ -178,6 +170,54 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } + private static float getWidth(int indexInBar, int divisor) + { + if (indexInBar == 0) + return 1; + + switch (divisor) + { + case 1: + case 2: + return 0.6f; + + case 3: + case 4: + return 0.5f; + + case 6: + case 8: + return 0.4f; + + default: + return 0.3f; + } + } + + private static float getHeight(int indexInBar, int divisor) + { + if (indexInBar == 0) + return 1; + + switch (divisor) + { + case 1: + case 2: + return 0.9f; + + case 3: + case 4: + return 0.8f; + + case 6: + case 8: + return 0.7f; + + default: + return 0.6f; + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs index ba94916458..fa51281c55 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs @@ -3,60 +3,27 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimingPointPiece : CompositeDrawable + public class TimingPointPiece : TopPointPiece { - private readonly TimingControlPoint point; - private readonly BindableNumber beatLength; - private OsuSpriteText bpmText; public TimingPointPiece(TimingControlPoint point) + : base(point) { - this.point = point; beatLength = point.BeatLengthBindable.GetBoundCopy(); } [BackgroundDependencyLoader] private void load(OsuColour colours) { - Origin = Anchor.CentreLeft; - Anchor = Anchor.CentreLeft; - - AutoSizeAxes = Axes.Both; - - Color4 colour = point.GetRepresentingColour(colours); - - InternalChildren = new Drawable[] - { - new Box - { - Alpha = 0.9f, - Colour = ColourInfo.GradientHorizontal(colour, colour.Opacity(0.5f)), - RelativeSizeAxes = Axes.Both, - }, - bpmText = new OsuSpriteText - { - Alpha = 0.9f, - Padding = new MarginPadding(3), - Font = OsuFont.Default.With(size: 40) - } - }; - beatLength.BindValueChanged(beatLength => { - bpmText.Text = $"{60000 / beatLength.NewValue:n1} BPM"; + Label.Text = $"{60000 / beatLength.NewValue:n1} BPM"; }, true); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs new file mode 100644 index 0000000000..60a9e1ed66 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TopPointPiece.cs @@ -0,0 +1,55 @@ +// 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.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public class TopPointPiece : CompositeDrawable + { + private readonly ControlPoint point; + + protected OsuSpriteText Label { get; private set; } + + public TopPointPiece(ControlPoint point) + { + this.point = point; + AutoSizeAxes = Axes.X; + Height = 16; + Margin = new MarginPadding(4); + + Masking = true; + CornerRadius = Height / 2; + + Origin = Anchor.TopCentre; + Anchor = Anchor.TopCentre; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new Drawable[] + { + new Box + { + Colour = point.GetRepresentingColour(colours), + RelativeSizeAxes = Axes.Both, + }, + Label = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(3), + Font = OsuFont.Default.With(size: 14, weight: FontWeight.SemiBold), + Colour = colours.B5, + } + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 81b1195a40..61056aeced 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -2,11 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System.Diagnostics; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; @@ -14,11 +19,17 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Edit.Compose { - public class ComposeScreen : EditorScreenWithTimeline + public class ComposeScreen : EditorScreenWithTimeline, IKeyBindingHandler { [Resolved] private IBindable beatmap { get; set; } + [Resolved] + private GameHost host { get; set; } + + [Resolved] + private EditorClock clock { get; set; } + private HitObjectComposer composer; public ComposeScreen() @@ -72,5 +83,34 @@ namespace osu.Game.Screens.Edit.Compose // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. return beatmapSkinProvider.WithChild(rulesetSkinProvider.WithChild(content)); } + + #region Input Handling + + public bool OnPressed(PlatformAction action) + { + if (action.ActionType == PlatformActionType.Copy) + host.GetClipboard().SetText(formatSelectionAsString()); + + return false; + } + + public void OnReleased(PlatformAction action) + { + } + + private string formatSelectionAsString() + { + if (composer == null) + return string.Empty; + + double displayTime = EditorBeatmap.SelectedHitObjects.OrderBy(h => h.StartTime).FirstOrDefault()?.StartTime ?? clock.CurrentTime; + string selectionAsString = composer.ConvertSelectionToString(); + + return !string.IsNullOrEmpty(selectionAsString) + ? $"{displayTime.ToEditorFormattedString()} ({selectionAsString}) - " + : $"{displayTime.ToEditorFormattedString()} - "; + } + + #endregion } } diff --git a/osu.Game/Screens/Edit/Compose/HitObjectUsageEventBuffer.cs b/osu.Game/Screens/Edit/Compose/HitObjectUsageEventBuffer.cs new file mode 100644 index 0000000000..621c901fb9 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/HitObjectUsageEventBuffer.cs @@ -0,0 +1,83 @@ +// 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 JetBrains.Annotations; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Screens.Edit.Compose +{ + /// + /// Buffers events from the many s in a nested hierarchy + /// to ensure correct ordering of events. + /// + internal class HitObjectUsageEventBuffer : IDisposable + { + /// + /// Invoked when a becomes used by a . + /// + /// + /// If the ruleset uses pooled objects, this represents the time when the s become alive. + /// + public event Action HitObjectUsageBegan; + + /// + /// Invoked when a becomes unused by a . + /// + /// + /// If the ruleset uses pooled objects, this represents the time when the s become dead. + /// + public event Action HitObjectUsageFinished; + + /// + /// Invoked when a has been transferred to another . + /// + public event Action HitObjectUsageTransferred; + + private readonly Playfield playfield; + + /// + /// Creates a new . + /// + /// The most top-level . + public HitObjectUsageEventBuffer([NotNull] Playfield playfield) + { + this.playfield = playfield; + + playfield.HitObjectUsageBegan += onHitObjectUsageBegan; + playfield.HitObjectUsageFinished += onHitObjectUsageFinished; + } + + private readonly List usageFinishedHitObjects = new List(); + + private void onHitObjectUsageBegan(HitObject hitObject) + { + if (usageFinishedHitObjects.Remove(hitObject)) + HitObjectUsageTransferred?.Invoke(hitObject, playfield.AllHitObjects.Single(d => d.HitObject == hitObject)); + else + HitObjectUsageBegan?.Invoke(hitObject); + } + + private void onHitObjectUsageFinished(HitObject hitObject) => usageFinishedHitObjects.Add(hitObject); + + public void Update() + { + foreach (var hitObject in usageFinishedHitObjects) + HitObjectUsageFinished?.Invoke(hitObject); + usageFinishedHitObjects.Clear(); + } + + public void Dispose() + { + if (playfield != null) + { + playfield.HitObjectUsageBegan -= onHitObjectUsageBegan; + playfield.HitObjectUsageFinished -= onHitObjectUsageFinished; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 0ba202b082..986a4efb28 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -16,9 +16,7 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Logging; -using osu.Framework.Platform; using osu.Framework.Screens; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; @@ -36,6 +34,7 @@ using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Design; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Timing; +using osu.Game.Screens.Edit.Verify; using osu.Game.Screens.Play; using osu.Game.Users; using osuTK.Graphics; @@ -74,7 +73,6 @@ namespace osu.Game.Screens.Edit private string lastSavedHash; - private Box bottomBackground; private Container screenContainer; private EditorScreen currentScreen; @@ -104,42 +102,29 @@ namespace osu.Game.Screens.Edit private MusicController music { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours, GameHost host, OsuConfigManager config) + private void load(OsuColour colours, OsuConfigManager config) { - if (Beatmap.Value is DummyWorkingBeatmap) + var loadableBeatmap = Beatmap.Value; + + if (loadableBeatmap is DummyWorkingBeatmap) { isNewBeatmap = true; - var newBeatmap = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value); + loadableBeatmap = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value); + + // required so we can get the track length in EditorClock. + // this is safe as nothing has yet got a reference to this new beatmap. + loadableBeatmap.LoadTrack(); // this is a bit haphazard, but guards against setting the lease Beatmap bindable if // the editor has already been exited. if (!ValidForPush) return; - - // this probably shouldn't be set in the asynchronous load method, but everything following relies on it. - Beatmap.Value = newBeatmap; } - beatDivisor.Value = Beatmap.Value.BeatmapInfo.BeatDivisor; - beatDivisor.BindValueChanged(divisor => Beatmap.Value.BeatmapInfo.BeatDivisor = divisor.NewValue); - - // Todo: should probably be done at a DrawableRuleset level to share logic with Player. - clock = new EditorClock(Beatmap.Value, beatDivisor) { IsCoupled = false }; - - UpdateClockSource(); - - dependencies.CacheAs(clock); - AddInternal(clock); - - clock.SeekingOrStopped.BindValueChanged(_ => updateSampleDisabledState()); - - // todo: remove caching of this and consume via editorBeatmap? - dependencies.Cache(beatDivisor); - try { - playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + playableBeatmap = loadableBeatmap.GetPlayableBeatmap(loadableBeatmap.BeatmapInfo.Ruleset); // clone these locally for now to avoid incurring overhead on GetPlayableBeatmap usages. // eventually we will want to improve how/where this is done as there are issues with *not* cloning it in all cases. @@ -153,13 +138,36 @@ namespace osu.Game.Screens.Edit return; } - AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, Beatmap.Value.Skin)); + beatDivisor.Value = playableBeatmap.BeatmapInfo.BeatDivisor; + beatDivisor.BindValueChanged(divisor => playableBeatmap.BeatmapInfo.BeatDivisor = divisor.NewValue); + + // Todo: should probably be done at a DrawableRuleset level to share logic with Player. + clock = new EditorClock(playableBeatmap, beatDivisor) { IsCoupled = false }; + clock.ChangeSource(loadableBeatmap.Track); + + dependencies.CacheAs(clock); + AddInternal(clock); + + clock.SeekingOrStopped.BindValueChanged(_ => updateSampleDisabledState()); + + // todo: remove caching of this and consume via editorBeatmap? + dependencies.Cache(beatDivisor); + + AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.Skin)); dependencies.CacheAs(editorBeatmap); changeHandler = new EditorChangeHandler(editorBeatmap); dependencies.CacheAs(changeHandler); updateLastSavedHash(); + Schedule(() => + { + // we need to avoid changing the beatmap from an asynchronous load thread. it can potentially cause weirdness including crashes. + // this assumes that nothing during the rest of this load() method is accessing Beatmap.Value (loadableBeatmap should be preferred). + // generally this is quite safe, as the actual load of editor content comes after menuBar.Mode.ValueChanged is fired in its own LoadComplete. + Beatmap.Value = loadableBeatmap; + }); + OsuMenuItem undoMenuItem; OsuMenuItem redoMenuItem; @@ -167,17 +175,6 @@ namespace osu.Game.Screens.Edit EditorMenuItem copyMenuItem; EditorMenuItem pasteMenuItem; - var fileMenuItems = new List - { - new EditorMenuItem("Save", MenuItemType.Standard, Save) - }; - - if (RuntimeInfo.IsDesktop) - fileMenuItems.Add(new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap)); - - fileMenuItems.Add(new EditorMenuItemSpacer()); - fileMenuItems.Add(new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit)); - AddInternal(new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, @@ -209,7 +206,7 @@ namespace osu.Game.Screens.Edit { new MenuItem("File") { - Items = fileMenuItems + Items = createFileMenuItems() }, new MenuItem("Edit") { @@ -225,9 +222,10 @@ namespace osu.Game.Screens.Edit }, new MenuItem("View") { - Items = new[] + Items = new MenuItem[] { - new WaveformOpacityMenu(config) + new WaveformOpacityMenuItem(config.GetBindable(OsuSetting.EditorWaveformOpacity)), + new HitAnimationsMenuItem(config.GetBindable(OsuSetting.EditorHitAnimations)) } } } @@ -242,7 +240,11 @@ namespace osu.Game.Screens.Edit Height = 60, Children = new Drawable[] { - bottomBackground = new Box { RelativeSizeAxes = Axes.Both }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray2 + }, new Container { RelativeSizeAxes = Axes.Both, @@ -299,18 +301,12 @@ namespace osu.Game.Screens.Edit clipboard.BindValueChanged(content => pasteMenuItem.Action.Disabled = string.IsNullOrEmpty(content.NewValue)); menuBar.Mode.ValueChanged += onModeChanged; - - bottomBackground.Colour = colours.Gray2; } /// /// If the beatmap's track has changed, this method must be called to keep the editor in a valid state. /// - public void UpdateClockSource() - { - var sourceClock = (IAdjustableClock)Beatmap.Value.Track ?? new StopwatchClock(); - clock.ChangeSource(sourceClock); - } + public void UpdateClockSource() => clock.ChangeSource(Beatmap.Value.Track); protected void Save() { @@ -444,6 +440,10 @@ namespace osu.Game.Screens.Edit menuBar.Mode.Value = EditorScreenMode.SongSetup; return true; + case GlobalAction.EditorVerifyMode: + menuBar.Mode.Value = EditorScreenMode.Verify; + return true; + default: return false; } @@ -462,7 +462,7 @@ namespace osu.Game.Screens.Edit // todo: temporary. we want to be applying dim using the UserDimContainer eventually. b.FadeColour(Color4.DarkGray, 500); - b.EnableUserDim.Value = false; + b.IgnoreUserSettings.Value = true; b.BlurAmount.Value = 0; }); @@ -473,25 +473,23 @@ namespace osu.Game.Screens.Edit { if (!exitConfirmed) { - // if the confirm dialog is already showing (or we can't show it, ie. in tests) exit without save. - if (dialogOverlay == null || dialogOverlay.CurrentDialog is PromptForSaveDialog) + // dialog overlay may not be available in visual tests. + if (dialogOverlay == null) { confirmExit(); - return base.OnExiting(next); + return true; + } + + // if the dialog is already displayed, confirm exit with no save. + if (dialogOverlay.CurrentDialog is PromptForSaveDialog saveDialog) + { + saveDialog.PerformOkAction(); + return true; } if (isNewBeatmap || HasUnsavedChanges) { - dialogOverlay?.Push(new PromptForSaveDialog(() => - { - confirmExit(); - this.Exit(); - }, () => - { - confirmExitWithSave(); - this.Exit(); - })); - + dialogOverlay?.Push(new PromptForSaveDialog(confirmExit, confirmExitWithSave)); return true; } } @@ -499,15 +497,23 @@ namespace osu.Game.Screens.Edit ApplyToBackground(b => b.FadeColour(Color4.White, 500)); resetTrack(); - Beatmap.Value = beatmapManager.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo); + // To update the game-wide beatmap with any changes, perform a re-fetch on exit. + // This is required as the editor makes its local changes via EditorBeatmap + // (which are not propagated outwards to a potentially cached WorkingBeatmap). + var refetchedBeatmap = beatmapManager.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo); + + if (!(refetchedBeatmap is DummyWorkingBeatmap)) + Beatmap.Value = refetchedBeatmap; return base.OnExiting(next); } private void confirmExitWithSave() { - exitConfirmed = true; Save(); + + exitConfirmed = true; + this.Exit(); } private void confirmExit() @@ -529,6 +535,7 @@ namespace osu.Game.Screens.Edit } exitConfirmed = true; + this.Exit(); } private readonly Bindable clipboard = new Bindable(); @@ -577,7 +584,7 @@ namespace osu.Game.Screens.Edit private void resetTrack(bool seekToStart = false) { - Beatmap.Value.Track?.Stop(); + Beatmap.Value.Track.Stop(); if (seekToStart) { @@ -631,6 +638,13 @@ namespace osu.Game.Screens.Edit case EditorScreenMode.Timing: currentScreen = new TimingScreen(); break; + + case EditorScreenMode.Verify: + currentScreen = new VerifyScreen(); + break; + + default: + throw new InvalidOperationException("Editor menu bar switched to an unsupported mode"); } LoadComponentAsync(currentScreen, newScreen => @@ -681,6 +695,21 @@ namespace osu.Game.Screens.Edit lastSavedHash = changeHandler.CurrentStateHash; } + private List createFileMenuItems() + { + var fileMenuItems = new List + { + new EditorMenuItem("Save", MenuItemType.Standard, Save) + }; + + if (RuntimeInfo.IsDesktop) + fileMenuItems.Add(new EditorMenuItem("Export package", MenuItemType.Standard, exportBeatmap)); + + fileMenuItems.Add(new EditorMenuItemSpacer()); + fileMenuItems.Add(new EditorMenuItem("Exit", MenuItemType.Standard, this.Exit)); + return fileMenuItems; + } + public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime); public double GetBeatLengthAtTime(double referenceTime) => editorBeatmap.GetBeatLengthAtTime(referenceTime); diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 174ff1478b..be53abbd55 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -46,6 +46,7 @@ namespace osu.Game.Screens.Edit public readonly IBeatmap PlayableBeatmap; + [CanBeNull] public readonly ISkin BeatmapSkin; [Resolved] @@ -100,6 +101,22 @@ namespace osu.Game.Screens.Edit private readonly HashSet batchPendingUpdates = new HashSet(); + /// + /// Perform the provided action on every selected hitobject. + /// Changes will be grouped as one history action. + /// + /// The action to perform. + public void PerformOnSelection(Action action) + { + if (SelectedHitObjects.Count == 0) + return; + + BeginChange(); + foreach (var h in SelectedHitObjects) + action(h); + EndChange(); + } + /// /// Adds a collection of s to this . /// @@ -284,13 +301,7 @@ namespace osu.Game.Screens.Edit return list.Count - 1; } - public double SnapTime(double time, double? referenceTime) - { - var timingPoint = ControlPointInfo.TimingPointAt(referenceTime ?? time); - var beatLength = timingPoint.BeatLength / BeatDivisor; - - return timingPoint.Time + (int)Math.Round((time - timingPoint.Time) / beatLength, MidpointRounding.AwayFromZero) * beatLength; - } + public double SnapTime(double time, double? referenceTime) => ControlPointInfo.GetClosestSnappedTime(time, BeatDivisor, referenceTime); public double GetBeatLengthAtTime(double referenceTime) => ControlPointInfo.TimingPointAt(referenceTime).BeatLength / BeatDivisor; diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index ec0f5d7154..772f6ea192 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -31,6 +31,8 @@ namespace osu.Game.Screens.Edit private readonly DecoupleableInterpolatingFramedClock underlyingClock; + private bool playbackFinished; + public IBindable SeekingOrStopped => seekingOrStopped; private readonly Bindable seekingOrStopped = new Bindable(true); @@ -40,12 +42,12 @@ namespace osu.Game.Screens.Edit /// public bool IsSeeking { get; private set; } - public EditorClock(WorkingBeatmap beatmap, BindableBeatDivisor beatDivisor) - : this(beatmap.Beatmap.ControlPointInfo, beatmap.Track.Length, beatDivisor) + public EditorClock(IBeatmap beatmap, BindableBeatDivisor beatDivisor) + : this(beatmap.ControlPointInfo, beatDivisor) { } - public EditorClock(ControlPointInfo controlPointInfo, double trackLength, BindableBeatDivisor beatDivisor) + public EditorClock(ControlPointInfo controlPointInfo, BindableBeatDivisor beatDivisor) { this.beatDivisor = beatDivisor; @@ -55,7 +57,7 @@ namespace osu.Game.Screens.Edit } public EditorClock() - : this(new ControlPointInfo(), 1000, new BindableBeatDivisor()) + : this(new ControlPointInfo(), new BindableBeatDivisor()) { } @@ -170,6 +172,10 @@ namespace osu.Game.Screens.Edit public void Start() { ClearTransforms(); + + if (playbackFinished) + underlyingClock.Seek(0); + underlyingClock.Start(); } @@ -216,7 +222,21 @@ namespace osu.Game.Screens.Edit public bool IsRunning => underlyingClock.IsRunning; - public void ProcessFrame() => underlyingClock.ProcessFrame(); + public void ProcessFrame() + { + underlyingClock.ProcessFrame(); + + playbackFinished = CurrentTime >= TrackLength; + + if (playbackFinished) + { + if (IsRunning) + underlyingClock.Stop(); + + if (CurrentTime > TrackLength) + underlyingClock.Seek(TrackLength); + } + } public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; diff --git a/osu.Game/Screens/Edit/EditorRoundedScreen.cs b/osu.Game/Screens/Edit/EditorRoundedScreen.cs new file mode 100644 index 0000000000..c6ced02021 --- /dev/null +++ b/osu.Game/Screens/Edit/EditorRoundedScreen.cs @@ -0,0 +1,61 @@ +// 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.Game.Graphics; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit +{ + public class EditorRoundedScreen : EditorScreen + { + public const int HORIZONTAL_PADDING = 100; + + [Resolved] + private OsuColour colours { get; set; } + + [Cached] + protected readonly OverlayColourProvider ColourProvider; + + private Container roundedContent; + + protected override Container Content => roundedContent; + + public EditorRoundedScreen(EditorScreenMode mode) + : base(mode) + { + ColourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + } + + [BackgroundDependencyLoader] + private void load() + { + base.Content.Add(new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(50), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + Colour = ColourProvider.Dark4, + RelativeSizeAxes = Axes.Both, + }, + roundedContent = new Container + { + RelativeSizeAxes = Axes.Both, + }, + } + } + }); + } + } +} diff --git a/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs b/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs new file mode 100644 index 0000000000..cb17484d27 --- /dev/null +++ b/osu.Game/Screens/Edit/EditorRoundedScreenSettings.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit +{ + public abstract class EditorRoundedScreenSettings : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Background4, + RelativeSizeAxes = Axes.Both, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = CreateSections() + }, + } + }; + } + + protected abstract IReadOnlyList CreateSections(); + } +} diff --git a/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs b/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs new file mode 100644 index 0000000000..e17114ebcb --- /dev/null +++ b/osu.Game/Screens/Edit/EditorRoundedScreenSettingsSection.cs @@ -0,0 +1,61 @@ +// 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.Sprites; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit +{ + public abstract class EditorRoundedScreenSettingsSection : CompositeDrawable + { + private const int header_height = 50; + + protected abstract string HeaderText { get; } + + protected FillFlowContainer Flow { get; private set; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = header_height, + Padding = new MarginPadding { Horizontal = 20 }, + Child = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = HeaderText, + Font = new FontUsage(size: 25, weight: "bold") + } + }, + new Container + { + Y = header_height, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = Flow = new FillFlowContainer + { + Padding = new MarginPadding { Horizontal = 20 }, + Spacing = new Vector2(10), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Edit/EditorScreenMode.cs b/osu.Game/Screens/Edit/EditorScreenMode.cs index 12cfcc605b..ecd39f9b57 100644 --- a/osu.Game/Screens/Edit/EditorScreenMode.cs +++ b/osu.Game/Screens/Edit/EditorScreenMode.cs @@ -18,5 +18,8 @@ namespace osu.Game.Screens.Edit [Description("timing")] Timing, + + [Description("verify")] + Verify, } } diff --git a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs index 2d623a200c..0d59a7a1a8 100644 --- a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs +++ b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs @@ -19,8 +19,6 @@ namespace osu.Game.Screens.Edit private const float vertical_margins = 10; private const float horizontal_margins = 20; - private const float timeline_height = 110; - private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); private Container timelineContainer; @@ -40,64 +38,87 @@ namespace osu.Game.Screens.Edit if (beatDivisor != null) this.beatDivisor.BindTo(beatDivisor); - Children = new Drawable[] + Child = new GridContainer { - mainContent = new Container + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - Name = "Main content", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Horizontal = horizontal_margins, - Top = vertical_margins + timeline_height, - Bottom = vertical_margins - }, - Child = spinner = new LoadingSpinner(true) - { - State = { Value = Visibility.Visible }, - }, + new Dimension(GridSizeMode.AutoSize), + new Dimension(), }, - new Container + Content = new[] { - Name = "Timeline", - RelativeSizeAxes = Axes.X, - Height = timeline_height, - Children = new Drawable[] + new Drawable[] { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f) - }, new Container { - Name = "Timeline content", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = horizontal_margins, Vertical = vertical_margins }, - Child = new GridContainer + Name = "Timeline", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Content = new[] + new Box { - new Drawable[] - { - timelineContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, - }, - new BeatDivisorControl(beatDivisor) { RelativeSizeAxes = Axes.Both } - }, + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.5f) }, - ColumnDimensions = new[] + new Container { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 90), + Name = "Timeline content", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = horizontal_margins, Vertical = vertical_margins }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + new Drawable[] + { + timelineContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 5 }, + }, + new BeatDivisorControl(beatDivisor) { RelativeSizeAxes = Axes.Both } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 90), + } + }, } + } + }, + }, + new Drawable[] + { + mainContent = new Container + { + Name = "Main content", + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Padding = new MarginPadding + { + Horizontal = horizontal_margins, + Top = vertical_margins, + Bottom = vertical_margins }, - } - } - }, + Child = spinner = new LoadingSpinner(true) + { + State = { Value = Visibility.Visible }, + }, + }, + }, + } }; } @@ -112,14 +133,7 @@ namespace osu.Game.Screens.Edit mainContent.Add(content); content.FadeInFromZero(300, Easing.OutQuint); - LoadComponentAsync(new TimelineArea - { - RelativeSizeAxes = Axes.Both, - Children = new[] - { - CreateTimelineContent(), - } - }, t => + LoadComponentAsync(new TimelineArea(CreateTimelineContent()), t => { timelineContainer.Add(t); OnTimelineLoaded(t); diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs new file mode 100644 index 0000000000..815f3ed0ea --- /dev/null +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -0,0 +1,141 @@ +// 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.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit +{ + public abstract class EditorTable : TableContainer + { + private const float horizontal_inset = 20; + + protected const float ROW_HEIGHT = 25; + + public const int TEXT_SIZE = 14; + + protected readonly FillFlowContainer BackgroundFlow; + + protected EditorTable() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Padding = new MarginPadding { Horizontal = horizontal_inset }; + RowSize = new Dimension(GridSizeMode.Absolute, ROW_HEIGHT); + + AddInternal(BackgroundFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Depth = 1f, + Padding = new MarginPadding { Horizontal = -horizontal_inset }, + Margin = new MarginPadding { Top = ROW_HEIGHT } + }); + } + + protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty); + + private class HeaderText : OsuSpriteText + { + public HeaderText(string text) + { + Text = text.ToUpper(); + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold); + } + } + + public class RowBackground : OsuClickableContainer + { + public readonly object Item; + + private const int fade_duration = 100; + + private readonly Box hoveredBackground; + + [Resolved] + private EditorClock clock { get; set; } + + public RowBackground(object item) + { + Item = item; + + RelativeSizeAxes = Axes.X; + Height = 25; + + AlwaysPresent = true; + + CornerRadius = 3; + Masking = true; + + Children = new Drawable[] + { + hoveredBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + }; + + // todo delete + Action = () => + { + }; + } + + private Color4 colourHover; + private Color4 colourSelected; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + hoveredBackground.Colour = colourHover = colours.Background1; + colourSelected = colours.Colour3; + } + + private bool selected; + + public bool Selected + { + get => selected; + set + { + if (value == selected) + return; + + selected = value; + updateState(); + } + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + hoveredBackground.FadeColour(selected ? colourSelected : colourHover, 450, Easing.OutQuint); + + if (selected || IsHovered) + hoveredBackground.FadeIn(fade_duration, Easing.OutQuint); + else + hoveredBackground.FadeOut(fade_duration, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs b/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs new file mode 100644 index 0000000000..fb7ab39f7a --- /dev/null +++ b/osu.Game/Screens/Edit/HitAnimationsMenuItem.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Bindables; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Edit +{ + internal class HitAnimationsMenuItem : ToggleMenuItem + { + [UsedImplicitly] + private readonly Bindable hitAnimations; + + public HitAnimationsMenuItem(Bindable hitAnimations) + : base("Hit animations") + { + State.BindTo(this.hitAnimations = hitAnimations); + } + } +} diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index f2e0320ce3..6f92db98ee 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -11,6 +11,7 @@ using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.IO; +using osu.Game.Skinning; using Decoder = osu.Game.Beatmaps.Formats.Decoder; namespace osu.Game.Screens.Edit @@ -116,6 +117,10 @@ namespace osu.Game.Screens.Edit protected override Texture GetBackground() => throw new NotImplementedException(); protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + + protected override ISkin GetSkin() => throw new NotImplementedException(); + + public override Stream GetStream(string storagePath) => throw new NotImplementedException(); } } } diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs new file mode 100644 index 0000000000..cb7deadcb7 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Setup +{ + internal class ColoursSection : SetupSection + { + public override LocalisableString Title => "Colours"; + + private LabelledColourPalette comboColours; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + comboColours = new LabelledColourPalette + { + Label = "Hitcircle / Slider Combos", + ColourNamePrefix = "Combo" + } + }; + + var colours = Beatmap.BeatmapSkin?.GetConfig>(GlobalSkinColours.ComboColours)?.Value; + if (colours != null) + comboColours.Colours.AddRange(colours); + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index 36fb0191b0..493d3ed20c 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -5,8 +5,8 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Beatmaps; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Screens.Edit.Setup @@ -18,15 +18,13 @@ namespace osu.Game.Screens.Edit.Setup private LabelledSliderBar approachRateSlider; private LabelledSliderBar overallDifficultySlider; + public override LocalisableString Title => "Difficulty"; + [BackgroundDependencyLoader] private void load() { Children = new Drawable[] { - new OsuSpriteText - { - Text = "Difficulty settings" - }, circleSizeSlider = new LabelledSliderBar { Label = "Object Size", diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs index 6e2737256a..a33a70af65 100644 --- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs +++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs @@ -2,12 +2,16 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Game.Database; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; @@ -17,27 +21,27 @@ namespace osu.Game.Screens.Edit.Setup /// /// A labelled textbox which reveals an inline file chooser when clicked. /// - internal class FileChooserLabelledTextBox : LabelledTextBox + internal class FileChooserLabelledTextBox : LabelledTextBox, ICanAcceptFiles { + private readonly string[] handledExtensions; + public IEnumerable HandledExtensions => handledExtensions; + + /// + /// The target container to display the file chooser in. + /// public Container Target; - private readonly IBindable currentFile = new Bindable(); + private readonly Bindable currentFile = new Bindable(); + + [Resolved] + private OsuGameBase game { get; set; } [Resolved] private SectionsContainer sectionsContainer { get; set; } - public FileChooserLabelledTextBox() + public FileChooserLabelledTextBox(params string[] handledExtensions) { - currentFile.BindValueChanged(onFileSelected); - } - - private void onFileSelected(ValueChangedEvent file) - { - if (file.NewValue == null) - return; - - Target.Clear(); - Current.Value = file.NewValue.FullName; + this.handledExtensions = handledExtensions; } protected override OsuTextBox CreateTextBox() => @@ -54,7 +58,7 @@ namespace osu.Game.Screens.Edit.Setup { FileSelector fileSelector; - Target.Child = fileSelector = new FileSelector(validFileExtensions: ResourcesSection.AudioExtensions) + Target.Child = fileSelector = new FileSelector(currentFile.Value?.DirectoryName, handledExtensions) { RelativeSizeAxes = Axes.X, Height = 400, @@ -64,6 +68,37 @@ namespace osu.Game.Screens.Edit.Setup sectionsContainer.ScrollTo(fileSelector); } + protected override void LoadComplete() + { + base.LoadComplete(); + + game.RegisterImportHandler(this); + currentFile.BindValueChanged(onFileSelected); + } + + private void onFileSelected(ValueChangedEvent file) + { + if (file.NewValue == null) + return; + + Target.Clear(); + Current.Value = file.NewValue.FullName; + } + + Task ICanAcceptFiles.Import(params string[] paths) + { + Schedule(() => currentFile.Value = new FileInfo(paths.First())); + return Task.CompletedTask; + } + + Task ICanAcceptFiles.Import(params ImportTask[] tasks) => throw new NotImplementedException(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + game.UnregisterImportHandler(this); + } + internal class FileChooserOsuTextBox : OsuTextBox { public Action OnFocused; diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index e812c042fb..889a5eab5e 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -5,7 +5,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Screens.Edit.Setup @@ -17,15 +17,13 @@ namespace osu.Game.Screens.Edit.Setup private LabelledTextBox creatorTextBox; private LabelledTextBox difficultyTextBox; + public override LocalisableString Title => "Metadata"; + [BackgroundDependencyLoader] private void load() { Children = new Drawable[] { - new OsuSpriteText - { - Text = "Beatmap metadata" - }, artistTextBox = new LabelledTextBox { Label = "Artist", @@ -56,6 +54,14 @@ namespace osu.Game.Screens.Edit.Setup item.OnCommit += onCommit; } + protected override void LoadComplete() + { + base.LoadComplete(); + + if (string.IsNullOrEmpty(artistTextBox.Current.Value)) + GetContainingInputManager().ChangeFocus(artistTextBox); + } + private void onCommit(TextBox sender, bool newText) { if (!newText) return; diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 1b841775e2..12270f2aa4 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -1,40 +1,25 @@ // 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.Linq; -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.Shapes; +using osu.Framework.Localisation; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Database; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Overlays; namespace osu.Game.Screens.Edit.Setup { - internal class ResourcesSection : SetupSection, ICanAcceptFiles + internal class ResourcesSection : SetupSection { private LabelledTextBox audioTrackTextBox; - private Container backgroundSpriteContainer; + private LabelledTextBox backgroundTextBox; - public IEnumerable HandledExtensions => ImageExtensions.Concat(AudioExtensions); - - public static string[] ImageExtensions { get; } = { ".jpg", ".jpeg", ".png" }; - - public static string[] AudioExtensions { get; } = { ".mp3", ".ogg" }; - - [Resolved] - private OsuGameBase game { get; set; } + public override LocalisableString Title => "Resources"; [Resolved] private MusicController music { get; set; } @@ -48,69 +33,66 @@ namespace osu.Game.Screens.Edit.Setup [Resolved(canBeNull: true)] private Editor editor { get; set; } + [Resolved] + private SetupScreenHeader header { get; set; } + [BackgroundDependencyLoader] private void load() { - Container audioTrackFileChooserContainer = new Container + Container audioTrackFileChooserContainer = createFileChooserContainer(); + Container backgroundFileChooserContainer = createFileChooserContainer(); + + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + backgroundTextBox = new FileChooserLabelledTextBox(".jpg", ".jpeg", ".png") + { + Label = "Background", + PlaceholderText = "Click to select a background image", + Current = { Value = working.Value.Metadata.BackgroundFile }, + Target = backgroundFileChooserContainer, + TabbableContentContainer = this + }, + backgroundFileChooserContainer, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + audioTrackTextBox = new FileChooserLabelledTextBox(".mp3", ".ogg") + { + Label = "Audio Track", + PlaceholderText = "Click to select a track", + Current = { Value = working.Value.Metadata.AudioFile }, + Target = audioTrackFileChooserContainer, + TabbableContentContainer = this + }, + audioTrackFileChooserContainer, + } + } + }; + + backgroundTextBox.Current.BindValueChanged(backgroundChanged); + audioTrackTextBox.Current.BindValueChanged(audioTrackChanged); + } + + private static Container createFileChooserContainer() => + new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, }; - Children = new Drawable[] - { - backgroundSpriteContainer = new Container - { - RelativeSizeAxes = Axes.X, - Height = 250, - Masking = true, - CornerRadius = 10, - }, - new OsuSpriteText - { - Text = "Resources" - }, - audioTrackTextBox = new FileChooserLabelledTextBox - { - Label = "Audio Track", - Current = { Value = working.Value.Metadata.AudioFile ?? "Click to select a track" }, - Target = audioTrackFileChooserContainer, - TabbableContentContainer = this - }, - audioTrackFileChooserContainer, - }; - - updateBackgroundSprite(); - - audioTrackTextBox.Current.BindValueChanged(audioTrackChanged); - } - - Task ICanAcceptFiles.Import(params string[] paths) - { - Schedule(() => - { - var firstFile = new FileInfo(paths.First()); - - if (ImageExtensions.Contains(firstFile.Extension)) - { - ChangeBackgroundImage(firstFile.FullName); - } - else if (AudioExtensions.Contains(firstFile.Extension)) - { - audioTrackTextBox.Text = firstFile.FullName; - } - }); - return Task.CompletedTask; - } - - Task ICanAcceptFiles.Import(params ImportTask[] tasks) => throw new NotImplementedException(); - - protected override void LoadComplete() - { - base.LoadComplete(); - game.RegisterImportHandler(this); - } - public bool ChangeBackgroundImage(string path) { var info = new FileInfo(path); @@ -133,17 +115,11 @@ namespace osu.Game.Screens.Edit.Setup } working.Value.Metadata.BackgroundFile = info.Name; - updateBackgroundSprite(); + header.Background.UpdateBackground(); return true; } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - game?.UnregisterImportHandler(this); - } - public bool ChangeAudioTrack(string path) { var info = new FileInfo(path); @@ -173,45 +149,16 @@ namespace osu.Game.Screens.Edit.Setup return true; } + private void backgroundChanged(ValueChangedEvent filePath) + { + if (!ChangeBackgroundImage(filePath.NewValue)) + backgroundTextBox.Current.Value = filePath.OldValue; + } + private void audioTrackChanged(ValueChangedEvent filePath) { if (!ChangeAudioTrack(filePath.NewValue)) audioTrackTextBox.Current.Value = filePath.OldValue; } - - private void updateBackgroundSprite() - { - LoadComponentAsync(new BeatmapBackgroundSprite(working.Value) - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fill, - }, background => - { - if (background.Texture != null) - backgroundSpriteContainer.Child = background; - else - { - backgroundSpriteContainer.Children = new Drawable[] - { - new Box - { - Colour = Colours.GreySeafoamDarker, - RelativeSizeAxes = Axes.Both, - }, - new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 24)) - { - Text = "Drag image here to set beatmap background!", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.X, - } - }; - } - - background.FadeInFromZero(500); - }); - } } } diff --git a/osu.Game/Screens/Edit/Setup/SetupScreen.cs b/osu.Game/Screens/Edit/Setup/SetupScreen.cs index 1c3cbb7206..5bbec2574f 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreen.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreen.cs @@ -3,76 +3,41 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Overlays; namespace osu.Game.Screens.Edit.Setup { - public class SetupScreen : EditorScreen + public class SetupScreen : EditorRoundedScreen { - [Resolved] - private OsuColour colours { get; set; } + [Cached] + private SectionsContainer sections = new SectionsContainer(); [Cached] - protected readonly OverlayColourProvider ColourProvider; + private SetupScreenHeader header = new SetupScreenHeader(); public SetupScreen() : base(EditorScreenMode.SongSetup) { - ColourProvider = new OverlayColourProvider(OverlayColourScheme.Green); } [BackgroundDependencyLoader] private void load() { - Child = new Container + AddRange(new Drawable[] { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(50), - Child = new Container + sections = new SectionsContainer { + FixedHeader = header, RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 10, - Children = new Drawable[] + Children = new SetupSection[] { - new Box - { - Colour = colours.GreySeafoamDark, - RelativeSizeAxes = Axes.Both, - }, - new SectionsContainer - { - FixedHeader = new SetupScreenHeader(), - RelativeSizeAxes = Axes.Both, - Children = new SetupSection[] - { - new ResourcesSection(), - new MetadataSection(), - new DifficultySection(), - } - }, + new ResourcesSection(), + new MetadataSection(), + new DifficultySection(), + new ColoursSection() } - } - }; - } - } - - internal class SetupScreenHeader : OverlayHeader - { - protected override OverlayTitle CreateTitle() => new SetupScreenTitle(); - - private class SetupScreenTitle : OverlayTitle - { - public SetupScreenTitle() - { - Title = "beatmap setup"; - Description = "change general settings of your beatmap"; - IconTexture = "Icons/Hexacons/social"; - } + }, + }); } } } diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs new file mode 100644 index 0000000000..2d0afda001 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs @@ -0,0 +1,120 @@ +// 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.Graphics.UserInterface; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Setup +{ + internal class SetupScreenHeader : OverlayHeader + { + public SetupScreenHeaderBackground Background { get; private set; } + + [Resolved] + private SectionsContainer sections { get; set; } + + private SetupScreenTabControl tabControl; + + protected override OverlayTitle CreateTitle() => new SetupScreenTitle(); + + protected override Drawable CreateContent() => new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + // reverse flow is used to ensure that the tab control's expandable bars extend over the background chooser. + Child = new ReverseChildIDFillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + tabControl = new SetupScreenTabControl + { + RelativeSizeAxes = Axes.X, + Height = 30 + }, + Background = new SetupScreenHeaderBackground + { + RelativeSizeAxes = Axes.X, + Height = 120 + } + } + } + }; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + tabControl.AccentColour = colourProvider.Highlight1; + tabControl.BackgroundColour = colourProvider.Dark5; + + foreach (var section in sections) + tabControl.AddItem(section); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + sections.SelectedSection.BindValueChanged(section => tabControl.Current.Value = section.NewValue); + tabControl.Current.BindValueChanged(section => + { + if (section.NewValue != sections.SelectedSection.Value) + sections.ScrollTo(section.NewValue); + }); + } + + private class SetupScreenTitle : OverlayTitle + { + public SetupScreenTitle() + { + Title = "beatmap setup"; + Description = "change general settings of your beatmap"; + IconTexture = "Icons/Hexacons/social"; + } + } + + internal class SetupScreenTabControl : OverlayTabControl + { + private readonly Box background; + + public Color4 BackgroundColour + { + get => background.Colour; + set => background.Colour = value; + } + + public SetupScreenTabControl() + { + TabContainer.Margin = new MarginPadding { Horizontal = EditorRoundedScreen.HORIZONTAL_PADDING }; + + AddInternal(background = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = 1 + }); + } + + protected override TabItem CreateTabItem(SetupSection value) => new SetupScreenTabItem(value) + { + AccentColour = AccentColour + }; + + private class SetupScreenTabItem : OverlayTabItem + { + public SetupScreenTabItem(SetupSection value) + : base(value) + { + Text.Text = value.Title; + } + } + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs new file mode 100644 index 0000000000..7304323004 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeaderBackground.cs @@ -0,0 +1,76 @@ +// 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.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Screens.Edit.Setup +{ + public class SetupScreenHeaderBackground : CompositeDrawable + { + [Resolved] + private OsuColour colours { get; set; } + + [Resolved] + private IBindable working { get; set; } + + private readonly Container content; + + public SetupScreenHeaderBackground() + { + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true + }; + } + + [BackgroundDependencyLoader] + private void load() + { + UpdateBackground(); + } + + public void UpdateBackground() + { + LoadComponentAsync(new BeatmapBackgroundSprite(working.Value) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + }, background => + { + if (background.Texture != null) + content.Child = background; + else + { + content.Children = new Drawable[] + { + new Box + { + Colour = colours.GreySeafoamDarker, + RelativeSizeAxes = Axes.Both, + }, + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 24)) + { + Text = "Drag image here to set beatmap background!", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both + } + }; + } + + background.FadeInFromZero(500); + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/SetupSection.cs b/osu.Game/Screens/Edit/Setup/SetupSection.cs index 88521a8fb0..8964e651df 100644 --- a/osu.Game/Screens/Edit/Setup/SetupSection.cs +++ b/osu.Game/Screens/Edit/Setup/SetupSection.cs @@ -4,12 +4,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osuTK; namespace osu.Game.Screens.Edit.Setup { - internal class SetupSection : Container + internal abstract class SetupSection : Container { private readonly FillFlowContainer flow; @@ -21,19 +23,40 @@ namespace osu.Game.Screens.Edit.Setup protected override Container Content => flow; - public SetupSection() + public abstract LocalisableString Title { get; } + + protected SetupSection() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Padding = new MarginPadding(10); + Padding = new MarginPadding + { + Vertical = 10, + Horizontal = EditorRoundedScreen.HORIZONTAL_PADDING + }; - InternalChild = flow = new FillFlowContainer + InternalChild = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Spacing = new Vector2(20), Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = Title + }, + flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(20), + Direction = FillDirection.Vertical, + } + } }; } } diff --git a/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs b/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs index c40061b97c..48639789af 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs @@ -2,44 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Edit.Timing { - public class ControlPointSettings : CompositeDrawable + public class ControlPointSettings : EditorRoundedScreenSettings { - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new Box - { - Colour = colours.Gray3, - RelativeSizeAxes = Axes.Both, - }, - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = createSections() - }, - } - }; - } - - private IReadOnlyList createSections() => new Drawable[] + protected override IReadOnlyList CreateSections() => new Drawable[] { new GroupSection(), new TimingSection(), diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index a17b431fcc..fe63138d28 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.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; @@ -8,59 +9,45 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Extensions; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Edit.Timing.RowAttributes; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.Edit.Timing { - public class ControlPointTable : TableContainer + public class ControlPointTable : EditorTable { - private const float horizontal_inset = 20; - private const float row_height = 25; - private const int text_size = 14; - - private readonly FillFlowContainer backgroundFlow; - [Resolved] private Bindable selectedGroup { get; set; } - public ControlPointTable() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; + [Resolved] + private EditorClock clock { get; set; } - Padding = new MarginPadding { Horizontal = horizontal_inset }; - RowSize = new Dimension(GridSizeMode.Absolute, row_height); - - AddInternal(backgroundFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Depth = 1f, - Padding = new MarginPadding { Horizontal = -horizontal_inset }, - Margin = new MarginPadding { Top = row_height } - }); - } + public const float TIMING_COLUMN_WIDTH = 220; public IEnumerable ControlGroups { set { Content = null; - backgroundFlow.Clear(); + BackgroundFlow.Clear(); if (value?.Any() != true) return; foreach (var group in value) { - backgroundFlow.Add(new RowBackground(group)); + BackgroundFlow.Add(new RowBackground(group) + { + Action = () => + { + selectedGroup.Value = group; + clock.SeekSmoothlyTo(group.Time); + } + }); } Columns = createHeaders(); @@ -68,48 +55,76 @@ namespace osu.Game.Screens.Edit.Timing } } + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedGroup.BindValueChanged(group => + { + foreach (var b in BackgroundFlow) b.Selected = b.Item == group.NewValue; + }, true); + } + private TableColumn[] createHeaders() { var columns = new List { - new TableColumn(string.Empty, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Time", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn(), + new TableColumn("Time", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, TIMING_COLUMN_WIDTH)), new TableColumn("Attributes", Anchor.CentreLeft), }; return columns.ToArray(); } - private Drawable[] createContent(int index, ControlPointGroup group) => new Drawable[] + private Drawable[] createContent(int index, ControlPointGroup group) { - new OsuSpriteText + return new Drawable[] { - Text = $"#{index + 1}", - Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold), - Margin = new MarginPadding(10) - }, - new OsuSpriteText - { - Text = group.Time.ToEditorFormattedString(), - Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) - }, - null, - new ControlGroupAttributes(group), - }; + new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + Width = TIMING_COLUMN_WIDTH, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = group.Time.ToEditorFormattedString(), + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + Width = 60, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new ControlGroupAttributes(group, c => c is TimingControlPoint) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + } + }, + new ControlGroupAttributes(group, c => !(c is TimingControlPoint)) + }; + } private class ControlGroupAttributes : CompositeDrawable { + private readonly Func matchFunction; + private readonly IBindableList controlPoints = new BindableList(); private readonly FillFlowContainer fill; - public ControlGroupAttributes(ControlPointGroup group) + public ControlGroupAttributes(ControlPointGroup group, Func matchFunction) { - RelativeSizeAxes = Axes.Both; + this.matchFunction = matchFunction; + + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + InternalChild = fill = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, Spacing = new Vector2(2) }; @@ -134,140 +149,34 @@ namespace osu.Game.Screens.Edit.Timing private void createChildren() { - fill.ChildrenEnumerable = controlPoints.Select(createAttribute).Where(c => c != null); + fill.ChildrenEnumerable = controlPoints + .Where(matchFunction) + .Select(createAttribute) + .Where(c => c != null) + // arbitrary ordering to make timing points first. + // probably want to explicitly define order in the future. + .OrderByDescending(c => c.GetType().Name); } private Drawable createAttribute(ControlPoint controlPoint) { - Color4 colour = controlPoint.GetRepresentingColour(colours); - switch (controlPoint) { case TimingControlPoint timing: - return new RowAttribute("timing", () => $"{60000 / timing.BeatLength:n1}bpm {timing.TimeSignature}", colour); + return new TimingRowAttribute(timing); case DifficultyControlPoint difficulty: - - return new RowAttribute("difficulty", () => $"{difficulty.SpeedMultiplier:n2}x", colour); + return new DifficultyRowAttribute(difficulty); case EffectControlPoint effect: - return new RowAttribute("effect", () => string.Join(" ", - effect.KiaiMode ? "Kiai" : string.Empty, - effect.OmitFirstBarLine ? "NoBarLine" : string.Empty - ).Trim(), colour); + return new EffectRowAttribute(effect); case SampleControlPoint sample: - return new RowAttribute("sample", () => $"{sample.SampleBank} {sample.SampleVolume}%", colour); + return new SampleRowAttribute(sample); } return null; } } - - protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty); - - private class HeaderText : OsuSpriteText - { - public HeaderText(string text) - { - Text = text.ToUpper(); - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold); - } - } - - public class RowBackground : OsuClickableContainer - { - private readonly ControlPointGroup controlGroup; - private const int fade_duration = 100; - - private readonly Box hoveredBackground; - - [Resolved] - private EditorClock clock { get; set; } - - [Resolved] - private Bindable selectedGroup { get; set; } - - public RowBackground(ControlPointGroup controlGroup) - { - this.controlGroup = controlGroup; - RelativeSizeAxes = Axes.X; - Height = 25; - - AlwaysPresent = true; - - CornerRadius = 3; - Masking = true; - - Children = new Drawable[] - { - hoveredBackground = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - }, - }; - - Action = () => - { - selectedGroup.Value = controlGroup; - clock.SeekSmoothlyTo(controlGroup.Time); - }; - } - - private Color4 colourHover; - private Color4 colourSelected; - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - hoveredBackground.Colour = colourHover = colours.BlueDarker; - colourSelected = colours.YellowDarker; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - selectedGroup.BindValueChanged(group => { Selected = controlGroup == group.NewValue; }, true); - } - - private bool selected; - - protected bool Selected - { - get => selected; - set - { - if (value == selected) - return; - - selected = value; - updateState(); - } - } - - protected override bool OnHover(HoverEvent e) - { - updateState(); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateState(); - base.OnHoverLost(e); - } - - private void updateState() - { - hoveredBackground.FadeColour(selected ? colourSelected : colourHover, 450, Easing.OutQuint); - - if (selected || IsHovered) - hoveredBackground.FadeIn(fade_duration, Easing.OutQuint); - else - hoveredBackground.FadeOut(fade_duration, Easing.OutQuint); - } - } } } diff --git a/osu.Game/Screens/Edit/Timing/DifficultySection.cs b/osu.Game/Screens/Edit/Timing/DifficultySection.cs index b87b8961f8..97d110c502 100644 --- a/osu.Game/Screens/Edit/Timing/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Timing/DifficultySection.cs @@ -18,7 +18,8 @@ namespace osu.Game.Screens.Edit.Timing { multiplierSlider = new SliderWithTextBoxInput("Speed Multiplier") { - Current = new DifficultyControlPoint().SpeedMultiplierBindable + Current = new DifficultyControlPoint().SpeedMultiplierBindable, + KeyboardStep = 0.1f } }); } @@ -27,7 +28,16 @@ namespace osu.Game.Screens.Edit.Timing { if (point.NewValue != null) { - multiplierSlider.Current = point.NewValue.SpeedMultiplierBindable; + var selectedPointBindable = point.NewValue.SpeedMultiplierBindable; + + // there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint). + // generally that level of precision could only be set by externally editing the .osu file, so at the point + // a user is looking to update this within the editor it should be safe to obliterate this additional precision. + double expectedPrecision = new DifficultyControlPoint().SpeedMultiplierBindable.Precision; + if (selectedPointBindable.Precision < expectedPrecision) + selectedPointBindable.Precision = expectedPrecision; + + multiplierSlider.Current = selectedPointBindable; multiplierSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); } } diff --git a/osu.Game/Screens/Edit/Timing/RowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttribute.cs index 2757e08026..74d43628e1 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttribute.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttribute.cs @@ -1,33 +1,35 @@ // 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.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osuTK.Graphics; +using osu.Game.Overlays; +using osuTK; namespace osu.Game.Screens.Edit.Timing { - public class RowAttribute : CompositeDrawable, IHasTooltip + public class RowAttribute : CompositeDrawable { - private readonly string header; - private readonly Func content; - private readonly Color4 colour; + protected readonly ControlPoint Point; - public RowAttribute(string header, Func content, Color4 colour) + private readonly string label; + + protected FillFlowContainer Content { get; private set; } + + public RowAttribute(ControlPoint point, string label) { - this.header = header; - this.content = content; - this.colour = colour; + Point = point; + + this.label = label; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider overlayColours) { AutoSizeAxes = Axes.X; @@ -37,27 +39,43 @@ namespace osu.Game.Screens.Edit.Timing Origin = Anchor.CentreLeft; Masking = true; - CornerRadius = 5; + CornerRadius = 3; InternalChildren = new Drawable[] { new Box { - Colour = colour, + Colour = overlayColours.Background4, RelativeSizeAxes = Axes.Both, }, - new OsuSpriteText + Content = new FillFlowContainer { - Padding = new MarginPadding(2), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.Default.With(weight: FontWeight.SemiBold, size: 12), - Text = header, - Colour = colours.Gray0 - }, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Horizontal = 5 }, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new Circle + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = Point.GetRepresentingColour(colours), + RelativeSizeAxes = Axes.Y, + Size = new Vector2(4, 0.6f), + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding(3), + Font = OsuFont.Default.With(weight: FontWeight.Bold, size: 12), + Text = label, + }, + }, + } }; } - - public string TooltipText => content(); } } diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs new file mode 100644 index 0000000000..6f7e790489 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeProgressBar.cs @@ -0,0 +1,41 @@ +// 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.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.Edit.Timing.RowAttributes +{ + public class AttributeProgressBar : ProgressBar + { + private readonly ControlPoint controlPoint; + + public AttributeProgressBar(ControlPoint controlPoint) + : base(false) + { + this.controlPoint = controlPoint; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider overlayColours) + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + Masking = true; + + RelativeSizeAxes = Axes.None; + + Size = new Vector2(80, 8); + CornerRadius = Height / 2; + + BackgroundColour = overlayColours.Background6; + FillColour = controlPoint.GetRepresentingColour(colours); + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeText.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeText.cs new file mode 100644 index 0000000000..d0a51f9faa --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/AttributeText.cs @@ -0,0 +1,32 @@ +// 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.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Edit.Timing.RowAttributes +{ + public class AttributeText : OsuSpriteText + { + private readonly ControlPoint controlPoint; + + public AttributeText(ControlPoint controlPoint) + { + this.controlPoint = controlPoint; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + Padding = new MarginPadding(6); + Font = OsuFont.Default.With(weight: FontWeight.Bold, size: 12); + Colour = controlPoint.GetRepresentingColour(colours); + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs new file mode 100644 index 0000000000..7b553ac7ad --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs @@ -0,0 +1,44 @@ +// 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.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Edit.Timing.RowAttributes +{ + public class DifficultyRowAttribute : RowAttribute + { + private readonly BindableNumber speedMultiplier; + + private OsuSpriteText text; + + public DifficultyRowAttribute(DifficultyControlPoint difficulty) + : base(difficulty, "difficulty") + { + speedMultiplier = difficulty.SpeedMultiplierBindable.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load() + { + Content.AddRange(new Drawable[] + { + new AttributeProgressBar(Point) + { + Current = speedMultiplier, + }, + text = new AttributeText(Point) + { + Width = 40, + }, + }); + + speedMultiplier.BindValueChanged(_ => updateText(), true); + } + + private void updateText() => text.Text = $"{speedMultiplier.Value:n2}x"; + } +} diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs new file mode 100644 index 0000000000..812407d6da --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs @@ -0,0 +1,38 @@ +// 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.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Screens.Edit.Timing.RowAttributes +{ + public class EffectRowAttribute : RowAttribute + { + private readonly Bindable kiaiMode; + private readonly Bindable omitBarLine; + private AttributeText kiaiModeBubble; + private AttributeText omitBarLineBubble; + + public EffectRowAttribute(EffectControlPoint effect) + : base(effect, "effect") + { + kiaiMode = effect.KiaiModeBindable.GetBoundCopy(); + omitBarLine = effect.OmitFirstBarLineBindable.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load() + { + Content.AddRange(new Drawable[] + { + kiaiModeBubble = new AttributeText(Point) { Text = "kiai" }, + omitBarLineBubble = new AttributeText(Point) { Text = "no barline" }, + }); + + kiaiMode.BindValueChanged(enabled => kiaiModeBubble.FadeTo(enabled.NewValue ? 1 : 0), true); + omitBarLine.BindValueChanged(enabled => omitBarLineBubble.FadeTo(enabled.NewValue ? 1 : 0), true); + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs new file mode 100644 index 0000000000..ac0797dba1 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/SampleRowAttribute.cs @@ -0,0 +1,57 @@ +// 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.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Edit.Timing.RowAttributes +{ + public class SampleRowAttribute : RowAttribute + { + private AttributeText sampleText; + private OsuSpriteText volumeText; + + private readonly Bindable sampleBank; + private readonly BindableNumber volume; + + public SampleRowAttribute(SampleControlPoint sample) + : base(sample, "sample") + { + sampleBank = sample.SampleBankBindable.GetBoundCopy(); + volume = sample.SampleVolumeBindable.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load() + { + AttributeProgressBar progress; + + Content.AddRange(new Drawable[] + { + sampleText = new AttributeText(Point), + progress = new AttributeProgressBar(Point), + volumeText = new AttributeText(Point) + { + Width = 40, + }, + }); + + volume.BindValueChanged(vol => + { + progress.Current.Value = vol.NewValue / 100f; + updateText(); + }, true); + + sampleBank.BindValueChanged(_ => updateText(), true); + } + + private void updateText() + { + volumeText.Text = $"{volume.Value}%"; + sampleText.Text = $"{sampleBank.Value}"; + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/TimingRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/TimingRowAttribute.cs new file mode 100644 index 0000000000..ab840e56a7 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/TimingRowAttribute.cs @@ -0,0 +1,38 @@ +// 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; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Edit.Timing.RowAttributes +{ + public class TimingRowAttribute : RowAttribute + { + private readonly BindableNumber beatLength; + private readonly Bindable timeSignature; + private OsuSpriteText text; + + public TimingRowAttribute(TimingControlPoint timing) + : base(timing, "timing") + { + timeSignature = timing.TimeSignatureBindable.GetBoundCopy(); + beatLength = timing.BeatLengthBindable.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load() + { + Content.Add(text = new AttributeText(Point)); + + timeSignature.BindValueChanged(_ => updateText()); + beatLength.BindValueChanged(_ => updateText(), true); + } + + private void updateText() => + text.Text = $"{60000 / beatLength.Value:n1}bpm {timeSignature.Value.GetDescription()}"; + } +} diff --git a/osu.Game/Screens/Edit/Timing/Section.cs b/osu.Game/Screens/Edit/Timing/Section.cs index 5269fa9774..8659b7aff6 100644 --- a/osu.Game/Screens/Edit/Timing/Section.cs +++ b/osu.Game/Screens/Edit/Timing/Section.cs @@ -8,8 +8,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; namespace osu.Game.Screens.Edit.Timing { @@ -23,7 +24,7 @@ namespace osu.Game.Screens.Edit.Timing protected Bindable ControlPoint { get; } = new Bindable(); - private const float header_height = 20; + private const float header_height = 50; [Resolved] protected EditorBeatmap Beatmap { get; private set; } @@ -35,7 +36,7 @@ namespace osu.Game.Screens.Edit.Timing protected IEditorChangeHandler ChangeHandler { get; private set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colours) { RelativeSizeAxes = Axes.X; AutoSizeDuration = 200; @@ -46,19 +47,17 @@ namespace osu.Game.Screens.Edit.Timing InternalChildren = new Drawable[] { - new Box - { - Colour = colours.Gray1, - RelativeSizeAxes = Axes.Both, - }, new Container { RelativeSizeAxes = Axes.X, Height = header_height, + Padding = new MarginPadding { Horizontal = 10 }, Children = new Drawable[] { checkbox = new OsuCheckbox { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, LabelText = typeof(T).Name.Replace(nameof(Beatmaps.ControlPoints.ControlPoint), string.Empty) } } @@ -72,12 +71,13 @@ namespace osu.Game.Screens.Edit.Timing { new Box { - Colour = colours.Gray2, + Colour = colours.Background3, RelativeSizeAxes = Axes.Both, }, Flow = new FillFlowContainer { - Padding = new MarginPadding(10), + Padding = new MarginPadding(20), + Spacing = new Vector2(20), RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, diff --git a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs index f2f9f76143..10a5771520 100644 --- a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs @@ -69,6 +69,15 @@ namespace osu.Game.Screens.Edit.Timing }, true); } + /// + /// A custom step value for each key press which actuates a change on this control. + /// + public float KeyboardStep + { + get => slider.KeyboardStep; + set => slider.KeyboardStep = value; + } + public Bindable Current { get => slider.Current; diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs index c5d2dd756a..a4193d5084 100644 --- a/osu.Game/Screens/Edit/Timing/TimingScreen.cs +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -8,15 +8,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; -using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Overlays; using osuTK; namespace osu.Game.Screens.Edit.Timing { - public class TimingScreen : EditorScreenWithTimeline + public class TimingScreen : EditorRoundedScreen { [Cached] private Bindable selectedGroup = new Bindable(); @@ -26,28 +25,26 @@ namespace osu.Game.Screens.Edit.Timing { } - protected override Drawable CreateMainContent() => new GridContainer + [BackgroundDependencyLoader] + private void load() { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] + Add(new GridContainer { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 200), - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - new ControlPointList(), - new ControlPointSettings(), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 350), }, - } - }; - - protected override void OnTimelineLoaded(TimelineArea timelineArea) - { - base.OnTimelineLoaded(timelineArea); - timelineArea.Timeline.Zoom = timelineArea.Timeline.MinZoom; + Content = new[] + { + new Drawable[] + { + new ControlPointList(), + new ControlPointSettings(), + }, + } + }); } public class ControlPointList : CompositeDrawable @@ -70,17 +67,24 @@ namespace osu.Game.Screens.Edit.Timing private IEditorChangeHandler changeHandler { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colours) { RelativeSizeAxes = Axes.Both; + const float margins = 10; InternalChildren = new Drawable[] { new Box { - Colour = colours.Gray0, + Colour = colours.Background3, RelativeSizeAxes = Axes.Both, }, + new Box + { + Colour = colours.Background2, + RelativeSizeAxes = Axes.Y, + Width = ControlPointTable.TIMING_COLUMN_WIDTH + margins, + }, new OsuScrollContainer { RelativeSizeAxes = Axes.Both, @@ -92,7 +96,7 @@ namespace osu.Game.Screens.Edit.Timing Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, Direction = FillDirection.Horizontal, - Margin = new MarginPadding(10), + Margin = new MarginPadding(margins), Spacing = new Vector2(5), Children = new Drawable[] { @@ -106,9 +110,9 @@ namespace osu.Game.Screens.Edit.Timing }, new OsuButton { - Text = "+", + Text = "+ Add at current time", Action = addNew, - Size = new Vector2(30, 30), + Size = new Vector2(160, 30), Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, }, diff --git a/osu.Game/Screens/Edit/Verify/InterpretationSection.cs b/osu.Game/Screens/Edit/Verify/InterpretationSection.cs new file mode 100644 index 0000000000..9548f8aaa9 --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/InterpretationSection.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Screens.Edit.Verify +{ + internal class InterpretationSection : EditorRoundedScreenSettingsSection + { + protected override string HeaderText => "Interpretation"; + + [BackgroundDependencyLoader] + private void load(VerifyScreen verify) + { + Flow.Add(new SettingsEnumDropdown + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + TooltipText = "Affects checks that depend on difficulty level", + Current = verify.InterpretedDifficulty.GetBoundCopy() + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs new file mode 100644 index 0000000000..0b1f988447 --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -0,0 +1,115 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; +using osuTK; + +namespace osu.Game.Screens.Edit.Verify +{ + [Cached] + public class IssueList : CompositeDrawable + { + private IssueTable table; + + [Resolved] + private EditorClock clock { get; set; } + + [Resolved] + private IBindable workingBeatmap { get; set; } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private VerifyScreen verify { get; set; } + + private IBeatmapVerifier rulesetVerifier; + private BeatmapVerifier generalVerifier; + private BeatmapVerifierContext context; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours) + { + generalVerifier = new BeatmapVerifier(); + rulesetVerifier = beatmap.BeatmapInfo.Ruleset?.CreateInstance()?.CreateBeatmapVerifier(); + + context = new BeatmapVerifierContext(beatmap, workingBeatmap.Value, verify.InterpretedDifficulty.Value); + verify.InterpretedDifficulty.BindValueChanged(difficulty => context.InterpretedDifficulty = difficulty.NewValue); + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Background2, + RelativeSizeAxes = Axes.Both, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = table = new IssueTable(), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(20), + Children = new Drawable[] + { + new TriangleButton + { + Text = "Refresh", + Action = refresh, + Size = new Vector2(120, 40), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + verify.InterpretedDifficulty.BindValueChanged(_ => refresh()); + verify.HiddenIssueTypes.BindCollectionChanged((_, __) => refresh()); + + refresh(); + } + + private void refresh() + { + var issues = generalVerifier.Run(context); + + if (rulesetVerifier != null) + issues = issues.Concat(rulesetVerifier.Run(context)); + + issues = filter(issues); + + table.Issues = issues + .OrderBy(issue => issue.Template.Type) + .ThenBy(issue => issue.Check.Metadata.Category); + } + + private IEnumerable filter(IEnumerable issues) + { + return issues.Where(issue => !verify.HiddenIssueTypes.Contains(issue.Template.Type)); + } + } +} diff --git a/osu.Game/Screens/Edit/Verify/IssueSettings.cs b/osu.Game/Screens/Edit/Verify/IssueSettings.cs new file mode 100644 index 0000000000..ae3ef7e0b0 --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/IssueSettings.cs @@ -0,0 +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.Collections.Generic; +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Edit.Verify +{ + public class IssueSettings : EditorRoundedScreenSettings + { + protected override IReadOnlyList CreateSections() => new Drawable[] + { + new InterpretationSection(), + new VisibilitySection() + }; + } +} diff --git a/osu.Game/Screens/Edit/Verify/IssueTable.cs b/osu.Game/Screens/Edit/Verify/IssueTable.cs new file mode 100644 index 0000000000..05a8fdd26d --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/IssueTable.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 System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Screens.Edit.Verify +{ + public class IssueTable : EditorTable + { + [Resolved] + private VerifyScreen verify { get; set; } + + private Bindable selectedIssue; + + [Resolved] + private EditorClock clock { get; set; } + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + + [Resolved] + private Editor editor { get; set; } + + public IEnumerable Issues + { + set + { + Content = null; + BackgroundFlow.Clear(); + + if (value == null) + return; + + foreach (var issue in value) + { + BackgroundFlow.Add(new RowBackground(issue) + { + Action = () => + { + selectedIssue.Value = issue; + + if (issue.Time != null) + { + clock.Seek(issue.Time.Value); + editor.OnPressed(GlobalAction.EditorComposeMode); + } + + if (!issue.HitObjects.Any()) + return; + + editorBeatmap.SelectedHitObjects.Clear(); + editorBeatmap.SelectedHitObjects.AddRange(issue.HitObjects); + }, + }); + } + + Columns = createHeaders(); + Content = value.Select((g, i) => createContent(i, g)).ToArray().ToRectangular(); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedIssue = verify.SelectedIssue.GetBoundCopy(); + selectedIssue.BindValueChanged(issue => + { + foreach (var b in BackgroundFlow) b.Selected = b.Item == issue.NewValue; + }, true); + } + + private TableColumn[] createHeaders() + { + var columns = new List + { + new TableColumn(string.Empty, Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Type", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize, minSize: 60)), + new TableColumn("Time", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize, minSize: 60)), + new TableColumn("Message", Anchor.CentreLeft), + new TableColumn("Category", Anchor.CentreRight, new Dimension(GridSizeMode.AutoSize)), + }; + + return columns.ToArray(); + } + + private Drawable[] createContent(int index, Issue issue) => new Drawable[] + { + new OsuSpriteText + { + Text = $"#{index + 1}", + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Medium), + Margin = new MarginPadding { Right = 10 } + }, + new OsuSpriteText + { + Text = issue.Template.Type.ToString(), + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + Margin = new MarginPadding { Right = 10 }, + Colour = issue.Template.Colour + }, + new OsuSpriteText + { + Text = issue.GetEditorTimestamp(), + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + Margin = new MarginPadding { Right = 10 }, + }, + new OsuSpriteText + { + Text = issue.ToString(), + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Medium) + }, + new OsuSpriteText + { + Text = issue.Check.Metadata.Category.ToString(), + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), + Margin = new MarginPadding(10) + } + }; + } +} diff --git a/osu.Game/Screens/Edit/Verify/VerifyScreen.cs b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs new file mode 100644 index 0000000000..6d7a4a72e2 --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/VerifyScreen.cs @@ -0,0 +1,59 @@ +// 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.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Screens.Edit.Verify +{ + [Cached] + public class VerifyScreen : EditorRoundedScreen + { + public readonly Bindable SelectedIssue = new Bindable(); + + public readonly Bindable InterpretedDifficulty = new Bindable(); + + public readonly BindableList HiddenIssueTypes = new BindableList { IssueType.Negligible }; + + public IssueList IssueList { get; private set; } + + public VerifyScreen() + : base(EditorScreenMode.Verify) + { + } + + [BackgroundDependencyLoader] + private void load() + { + InterpretedDifficulty.Default = EditorBeatmap.BeatmapInfo.DifficultyRating; + InterpretedDifficulty.SetDefault(); + + IssueList = new IssueList(); + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 200), + }, + Content = new[] + { + new Drawable[] + { + IssueList, + new IssueSettings(), + }, + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Verify/VisibilitySection.cs b/osu.Game/Screens/Edit/Verify/VisibilitySection.cs new file mode 100644 index 0000000000..d049436376 --- /dev/null +++ b/osu.Game/Screens/Edit/Verify/VisibilitySection.cs @@ -0,0 +1,54 @@ +// 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.Game.Overlays; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Screens.Edit.Verify +{ + internal class VisibilitySection : EditorRoundedScreenSettingsSection + { + private readonly IssueType[] configurableIssueTypes = + { + IssueType.Warning, + IssueType.Error, + IssueType.Negligible + }; + + private BindableList hiddenIssueTypes; + + protected override string HeaderText => "Visibility"; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colours, VerifyScreen verify) + { + hiddenIssueTypes = verify.HiddenIssueTypes.GetBoundCopy(); + + foreach (IssueType issueType in configurableIssueTypes) + { + var checkbox = new SettingsCheckbox + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + LabelText = issueType.ToString(), + Current = { Default = !hiddenIssueTypes.Contains(issueType) } + }; + + checkbox.Current.SetDefault(); + checkbox.Current.BindValueChanged(state => + { + if (!state.NewValue) + hiddenIssueTypes.Add(issueType); + else + hiddenIssueTypes.Remove(issueType); + }); + + Flow.Add(checkbox); + } + } + } +} diff --git a/osu.Game/Screens/Edit/WaveformOpacityMenu.cs b/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs similarity index 65% rename from osu.Game/Screens/Edit/WaveformOpacityMenu.cs rename to osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs index 5d209ae141..7e095f526e 100644 --- a/osu.Game/Screens/Edit/WaveformOpacityMenu.cs +++ b/osu.Game/Screens/Edit/WaveformOpacityMenuItem.cs @@ -4,18 +4,17 @@ using System.Collections.Generic; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; -using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Edit { - internal class WaveformOpacityMenu : MenuItem + internal class WaveformOpacityMenuItem : MenuItem { private readonly Bindable waveformOpacity; - private readonly Dictionary menuItemLookup = new Dictionary(); + private readonly Dictionary menuItemLookup = new Dictionary(); - public WaveformOpacityMenu(OsuConfigManager config) + public WaveformOpacityMenuItem(Bindable waveformOpacity) : base("Waveform opacity") { Items = new[] @@ -26,17 +25,17 @@ namespace osu.Game.Screens.Edit createMenuItem(1f), }; - waveformOpacity = config.GetBindable(OsuSetting.EditorWaveformOpacity); + this.waveformOpacity = waveformOpacity; waveformOpacity.BindValueChanged(opacity => { foreach (var kvp in menuItemLookup) - kvp.Value.State.Value = kvp.Key == opacity.NewValue; + kvp.Value.State.Value = kvp.Key == opacity.NewValue ? TernaryState.True : TernaryState.False; }, true); } - private ToggleMenuItem createMenuItem(float opacity) + private TernaryStateRadioMenuItem createMenuItem(float opacity) { - var item = new ToggleMenuItem($"{opacity * 100}%", MenuItemType.Standard, _ => updateOpacity(opacity)); + var item = new TernaryStateRadioMenuItem($"{opacity * 100}%", MenuItemType.Standard, _ => updateOpacity(opacity)); menuItemLookup[opacity] = item; return item; } diff --git a/osu.Game/Screens/IHandlePresentBeatmap.cs b/osu.Game/Screens/IHandlePresentBeatmap.cs new file mode 100644 index 0000000000..60801fb3eb --- /dev/null +++ b/osu.Game/Screens/IHandlePresentBeatmap.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Rulesets; + +namespace osu.Game.Screens +{ + /// + /// Denotes a screen which can handle beatmap / ruleset selection via local logic. + /// This is used in the flow to handle cases which require custom logic, + /// for instance, if a lease is held on the Beatmap. + /// + public interface IHandlePresentBeatmap + { + /// + /// Invoked with a requested beatmap / ruleset for selection. + /// + /// The beatmap to be selected. + /// The ruleset to be selected. + void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset); + } +} diff --git a/osu.Game/Screens/IHasSubScreenStack.cs b/osu.Game/Screens/IHasSubScreenStack.cs new file mode 100644 index 0000000000..c5e2015109 --- /dev/null +++ b/osu.Game/Screens/IHasSubScreenStack.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 osu.Framework.Screens; + +namespace osu.Game.Screens +{ + /// + /// A screen which manages a nested stack of screens within itself. + /// + public interface IHasSubScreenStack + { + ScreenStack SubScreenStack { get; } + } +} diff --git a/osu.Game/Screens/Import/FileImportScreen.cs b/osu.Game/Screens/Import/FileImportScreen.cs index 329623e03a..ee8ef6926d 100644 --- a/osu.Game/Screens/Import/FileImportScreen.cs +++ b/osu.Game/Screens/Import/FileImportScreen.cs @@ -154,7 +154,7 @@ namespace osu.Game.Screens.Import Task.Factory.StartNew(async () => { - await game.Import(path); + await game.Import(path).ConfigureAwait(false); // some files will be deleted after successful import, so we want to refresh the view. Schedule(() => diff --git a/osu.Game/Screens/Menu/Button.cs b/osu.Game/Screens/Menu/Button.cs index d956394ebb..26f26d1304 100644 --- a/osu.Game/Screens/Menu/Button.cs +++ b/osu.Game/Screens/Menu/Button.cs @@ -19,6 +19,7 @@ using osu.Game.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Screens.Menu @@ -50,7 +51,7 @@ namespace osu.Game.Screens.Menu public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); - public Button(string text, string sampleName, IconUsage symbol, Color4 colour, Action clickAction = null, float extraWidth = 0, Key triggerKey = Key.Unknown) + public Button(LocalisableString text, string sampleName, IconUsage symbol, Color4 colour, Action clickAction = null, float extraWidth = 0, Key triggerKey = Key.Unknown) { this.sampleName = sampleName; this.clickAction = clickAction; diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 81b1cb0bf1..a836f7bf09 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Threading; @@ -22,6 +23,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -97,8 +99,8 @@ namespace osu.Game.Screens.Menu buttonArea.AddRange(new Drawable[] { - new Button(@"settings", string.Empty, FontAwesome.Solid.Cog, new Color4(85, 85, 85, 255), () => OnSettings?.Invoke(), -WEDGE_WIDTH, Key.O), - backButton = new Button(@"back", @"button-back-select", OsuIcon.LeftCircle, new Color4(51, 58, 94, 255), () => State = ButtonSystemState.TopLevel, -WEDGE_WIDTH) + new Button(ButtonSystemStrings.Settings, string.Empty, FontAwesome.Solid.Cog, new Color4(85, 85, 85, 255), () => OnSettings?.Invoke(), -WEDGE_WIDTH, Key.O), + backButton = new Button(ButtonSystemStrings.Back, @"button-back-select", OsuIcon.LeftCircle, new Color4(51, 58, 94, 255), () => State = ButtonSystemState.TopLevel, -WEDGE_WIDTH) { VisibleState = ButtonSystemState.Play, }, @@ -121,19 +123,19 @@ namespace osu.Game.Screens.Menu private LoginOverlay loginOverlay { get; set; } [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, IdleTracker idleTracker, GameHost host) + private void load(AudioManager audio, IdleTracker idleTracker, GameHost host, LocalisationManager strings) { - buttonsPlay.Add(new Button(@"solo", @"button-solo-select", FontAwesome.Solid.User, new Color4(102, 68, 204, 255), () => OnSolo?.Invoke(), WEDGE_WIDTH, Key.P)); - buttonsPlay.Add(new Button(@"multi", @"button-generic-select", FontAwesome.Solid.Users, new Color4(94, 63, 186, 255), onMultiplayer, 0, Key.M)); - buttonsPlay.Add(new Button(@"playlists", @"button-generic-select", OsuIcon.Charts, new Color4(94, 63, 186, 255), onPlaylists, 0, Key.L)); + buttonsPlay.Add(new Button(ButtonSystemStrings.Solo, @"button-solo-select", FontAwesome.Solid.User, new Color4(102, 68, 204, 255), () => OnSolo?.Invoke(), WEDGE_WIDTH, Key.P)); + buttonsPlay.Add(new Button(ButtonSystemStrings.Multi, @"button-generic-select", FontAwesome.Solid.Users, new Color4(94, 63, 186, 255), onMultiplayer, 0, Key.M)); + buttonsPlay.Add(new Button(ButtonSystemStrings.Playlists, @"button-generic-select", OsuIcon.Charts, new Color4(94, 63, 186, 255), onPlaylists, 0, Key.L)); buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play); - buttonsTopLevel.Add(new Button(@"play", @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), () => State = ButtonSystemState.Play, WEDGE_WIDTH, Key.P)); - buttonsTopLevel.Add(new Button(@"edit", @"button-edit-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), () => OnEdit?.Invoke(), 0, Key.E)); - buttonsTopLevel.Add(new Button(@"browse", @"button-direct-select", OsuIcon.ChevronDownCircle, new Color4(165, 204, 0, 255), () => OnBeatmapListing?.Invoke(), 0, Key.D)); + buttonsTopLevel.Add(new Button(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), () => State = ButtonSystemState.Play, WEDGE_WIDTH, Key.P)); + buttonsTopLevel.Add(new Button(ButtonSystemStrings.Edit, @"button-edit-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), () => OnEdit?.Invoke(), 0, Key.E)); + buttonsTopLevel.Add(new Button(ButtonSystemStrings.Browse, @"button-direct-select", OsuIcon.ChevronDownCircle, new Color4(165, 204, 0, 255), () => OnBeatmapListing?.Invoke(), 0, Key.D)); if (host.CanExit) - buttonsTopLevel.Add(new Button(@"exit", string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), () => OnExit?.Invoke(), 0, Key.Q)); + buttonsTopLevel.Add(new Button(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), () => OnExit?.Invoke(), 0, Key.Q)); buttonArea.AddRange(buttonsPlay); buttonArea.AddRange(buttonsTopLevel); diff --git a/osu.Game/Screens/Menu/ConfirmExitDialog.cs b/osu.Game/Screens/Menu/ConfirmExitDialog.cs index d120eb21a8..6488a2fd63 100644 --- a/osu.Game/Screens/Menu/ConfirmExitDialog.cs +++ b/osu.Game/Screens/Menu/ConfirmExitDialog.cs @@ -9,10 +9,15 @@ namespace osu.Game.Screens.Menu { public class ConfirmExitDialog : PopupDialog { - public ConfirmExitDialog(Action confirm, Action cancel) + /// + /// Construct a new exit confirmation dialog. + /// + /// An action to perform on confirmation. + /// An optional action to perform on cancel. + public ConfirmExitDialog(Action onConfirm, Action onCancel = null) { - HeaderText = "Are you sure you want to exit?"; - BodyText = "Last chance to back out."; + HeaderText = "Are you sure you want to exit osu!?"; + BodyText = "Last chance to turn back"; Icon = FontAwesome.Solid.ExclamationTriangle; @@ -20,13 +25,13 @@ namespace osu.Game.Screens.Menu { new PopupDialogOkButton { - Text = @"Goodbye", - Action = confirm + Text = @"Let me out!", + Action = onConfirm }, new PopupDialogCancelButton { - Text = @"Just a little more", - Action = cancel + Text = @"Just a little more...", + Action = onCancel }, }; } diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs index ffe6882a72..abe6c62461 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -170,7 +170,7 @@ namespace osu.Game.Screens.Menu rulesets.Hide(); lazerLogo.Hide(); - background.Hide(); + background.ApplyToBackground(b => b.Hide()); using (BeginAbsoluteSequence(0, true)) { @@ -231,7 +231,8 @@ namespace osu.Game.Screens.Menu lazerLogo.Dispose(); // explicit disposal as we are pushing a new screen and the expire may not get run. logo.FadeIn(); - background.FadeIn(); + + background.ApplyToBackground(b => b.Show()); game.Add(new GameWideFlash()); diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 424e6d2cd5..e53b46f391 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -1,29 +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.Linq; -using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.IO; using osu.Game.Online.API; using osu.Game.Overlays; +using osu.Game.Rulesets; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Select; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Menu { - public class MainMenu : OsuScreen + public class MainMenu : OsuScreen, IHandlePresentBeatmap { public const float FADE_IN_DURATION = 300; @@ -104,7 +105,7 @@ namespace osu.Game.Screens.Menu Beatmap.SetDefault(); this.Push(new Editor()); }, - OnSolo = onSolo, + OnSolo = loadSoloSongSelect, OnMultiplayer = () => this.Push(new Multiplayer()), OnPlaylists = () => this.Push(new Playlists()), OnExit = confirmAndExit, @@ -118,7 +119,7 @@ namespace osu.Game.Screens.Menu Origin = Anchor.TopRight, Margin = new MarginPadding { Right = 15, Top = 5 } }, - exitConfirmOverlay?.CreateProxy() ?? Drawable.Empty() + exitConfirmOverlay?.CreateProxy() ?? Empty() }); buttons.StateChanged += state => @@ -160,9 +161,7 @@ namespace osu.Game.Screens.Menu LoadComponentAsync(songSelect = new PlaySongSelect()); } - public void LoadToSolo() => Schedule(onSolo); - - private void onSolo() => this.Push(consumeSongSelect()); + private void loadSoloSongSelect() => this.Push(consumeSongSelect()); private Screen consumeSongSelect() { @@ -270,15 +269,11 @@ namespace osu.Game.Screens.Menu if (!exitConfirmed && dialogOverlay != null) { if (dialogOverlay.CurrentDialog is ConfirmExitDialog exitDialog) - { - exitConfirmed = true; - exitDialog.Buttons.First().Click(); - } + exitDialog.PerformOkAction(); else - { dialogOverlay.Push(new ConfirmExitDialog(confirmAndExit, () => exitConfirmOverlay.Abort())); - return true; - } + + return true; } buttons.State = ButtonSystemState.Exit; @@ -289,5 +284,13 @@ namespace osu.Game.Screens.Menu this.FadeOut(3000); return base.OnExiting(next); } + + public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + Beatmap.Value = beatmap; + Ruleset.Value = ruleset; + + Schedule(loadSoloSongSelect); + } } } diff --git a/osu.Game/Screens/Menu/SongTicker.cs b/osu.Game/Screens/Menu/SongTicker.cs index c4943e77d5..237fe43168 100644 --- a/osu.Game/Screens/Menu/SongTicker.cs +++ b/osu.Game/Screens/Menu/SongTicker.cs @@ -8,8 +8,8 @@ using osu.Game.Graphics.Sprites; using osuTK; using osu.Game.Graphics; using osu.Framework.Bindables; -using osu.Game.Beatmaps; using osu.Framework.Localisation; +using osu.Game.Beatmaps; namespace osu.Game.Screens.Menu { @@ -61,8 +61,8 @@ namespace osu.Game.Screens.Menu { var metadata = beatmap.Value.Metadata; - title.Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)); - artist.Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)); + title.Text = new RomanisableString(metadata.TitleUnicode, metadata.Title); + artist.Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); this.FadeInFromZero(fade_duration / 2f) .Delay(4000) diff --git a/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs index acb82360b3..e5a5e35897 100644 --- a/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs +++ b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs @@ -73,7 +73,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { new OsuSpriteText { - Text = new LocalisedString((beatmap.Value.Metadata.ArtistUnicode, beatmap.Value.Metadata.Artist)), + Text = new RomanisableString(beatmap.Value.Metadata.ArtistUnicode, beatmap.Value.Metadata.Artist), Font = OsuFont.GetFont(size: TextSize), }, new OsuSpriteText @@ -83,7 +83,7 @@ namespace osu.Game.Screens.OnlinePlay.Components }, new OsuSpriteText { - Text = new LocalisedString((beatmap.Value.Metadata.TitleUnicode, beatmap.Value.Metadata.Title)), + Text = new RomanisableString(beatmap.Value.Metadata.TitleUnicode, beatmap.Value.Metadata.Title), Font = OsuFont.GetFont(size: TextSize), } }, LinkAction.OpenBeatmap, beatmap.Value.OnlineBeatmapID.ToString(), "Open beatmap"); diff --git a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs index 6eb675976a..8f85608b29 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ReadyButton.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.OnlinePlay.Components private IBindable availability; [BackgroundDependencyLoader] - private void load(OnlinePlayBeatmapAvailablilityTracker beatmapTracker) + private void load(OnlinePlayBeatmapAvailabilityTracker beatmapTracker) { availability = beatmapTracker.Availability.GetBoundCopy(); diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 23c713a2c1..38a9ace619 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -108,7 +108,7 @@ namespace osu.Game.Screens.OnlinePlay difficultyIconContainer.Child = new DifficultyIcon(beatmap.Value, ruleset.Value, requiredMods) { Size = new Vector2(32) }; beatmapText.Clear(); - beatmapText.AddLink(Item.Beatmap.ToString(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString()); + beatmapText.AddLink(Item.Beatmap.Value.ToRomanisableString(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString()); authorText.Clear(); diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs index ab7be13479..66262e7dc4 100644 --- a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs @@ -75,6 +75,14 @@ namespace osu.Game.Screens.OnlinePlay section.DeselectAll(); } + protected override void OnAvailableModsChanged() + { + base.OnAvailableModsChanged(); + + foreach (var section in ModSectionsContainer.Children) + ((FreeModSection)section).UpdateCheckboxState(); + } + protected override ModSection CreateModSection(ModType type) => new FreeModSection(type); private class FreeModSection : ModSection @@ -108,10 +116,14 @@ namespace osu.Game.Screens.OnlinePlay protected override void ModButtonStateChanged(Mod mod) { base.ModButtonStateChanged(mod); + UpdateCheckboxState(); + } + public void UpdateCheckboxState() + { if (!SelectionAnimationRunning) { - var validButtons = ButtonsContainer.OfType().Where(b => b.Mod.HasImplementation); + var validButtons = Buttons.Where(b => b.Mod.HasImplementation); checkbox.Current.Value = validButtons.All(b => b.Selected); } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index 0a7198a7fa..35782c6104 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -39,7 +39,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public event Action StateChanged; private readonly Box selectionBox; - private CachedModelDependencyContainer dependencies; [Resolved(canBeNull: true)] private OnlinePlayScreen parentScreen { get; set; } @@ -209,9 +208,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - dependencies = new CachedModelDependencyContainer(base.CreateChildDependencies(parent)); - dependencies.Model.Value = Room; - return dependencies; + return new CachedModelDependencyContainer(base.CreateChildDependencies(parent)) + { + Model = { Value = Room } + }; } protected override void LoadComplete() diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index f13d623eae..f24577a8a5 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -170,7 +169,9 @@ namespace osu.Game.Screens.OnlinePlay.Lounge private void joinRequested(Room room) { - Debug.Assert(joiningRoomOperation == null); + if (joiningRoomOperation != null) + return; + joiningRoomOperation = ongoingOperationTracker?.BeginOperation(); RoomManager?.JoinRoom(room, r => diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs index ea3951fc3b..5699da740c 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs @@ -19,6 +19,8 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components protected OnlinePlayComposite Settings { get; set; } + protected override bool BlockScrollInput => false; + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 4a689314db..a53e253581 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -17,7 +17,6 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Play; namespace osu.Game.Screens.OnlinePlay.Match { @@ -56,15 +55,15 @@ namespace osu.Game.Screens.OnlinePlay.Match private IBindable> managerUpdated; [Cached] - protected OnlinePlayBeatmapAvailablilityTracker BeatmapAvailablilityTracker { get; } + protected OnlinePlayBeatmapAvailabilityTracker BeatmapAvailabilityTracker { get; } - protected IBindable BeatmapAvailability => BeatmapAvailablilityTracker.Availability; + protected IBindable BeatmapAvailability => BeatmapAvailabilityTracker.Availability; protected RoomSubScreen() { AddRangeInternal(new Drawable[] { - BeatmapAvailablilityTracker = new OnlinePlayBeatmapAvailablilityTracker + BeatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker { SelectedItem = { BindTarget = SelectedItem } }, @@ -73,8 +72,8 @@ namespace osu.Game.Screens.OnlinePlay.Match Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Depth = float.MinValue, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }, Child = userModsSelectOverlay = new UserModSelectOverlay { @@ -148,12 +147,22 @@ namespace osu.Game.Screens.OnlinePlay.Match return base.OnExiting(next); } - protected void StartPlay(Func player) + protected void StartPlay() { sampleStart?.Play(); - ParentScreen?.Push(new PlayerLoader(player)); + + // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). + var targetScreen = (Screen)ParentScreen ?? this; + + targetScreen.Push(CreateGameplayScreen()); } + /// + /// Creates the gameplay screen to be entered. + /// + /// The screen to enter. + protected abstract Screen CreateGameplayScreen(); + private void selectedItemChanged() { updateWorkingBeatmap(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs index a13d2cf540..cc51b5b691 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/CreateMultiplayerMatchButton.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private IBindable operationInProgress; [Resolved] - private StatefulMultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs index fdc1ae9d3c..d4f5428bfb 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs @@ -8,21 +8,28 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public class MultiplayerMatchFooter : CompositeDrawable { public const float HEIGHT = 50; + private const float ready_button_width = 600; + private const float spectate_button_width = 200; public Action OnReadyClick { set => readyButton.OnReadyClick = value; } + public Action OnSpectateClick + { + set => spectateButton.OnSpectateClick = value; + } + private readonly Drawable background; private readonly MultiplayerReadyButton readyButton; + private readonly MultiplayerSpectateButton spectateButton; public MultiplayerMatchFooter() { @@ -32,11 +39,34 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match InternalChildren = new[] { background = new Box { RelativeSizeAxes = Axes.Both }, - readyButton = new MultiplayerReadyButton + new GridContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(600, 50), + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + null, + spectateButton = new MultiplayerSpectateButton + { + RelativeSizeAxes = Axes.Both, + }, + null, + readyButton = new MultiplayerReadyButton + { + RelativeSizeAxes = Axes.Both, + }, + null + } + }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(maxSize: spectate_button_width), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(maxSize: ready_button_width), + new Dimension() + } } }; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 3199232f6f..fe9979b161 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -59,7 +59,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private IRoomManager manager { get; set; } [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } [Resolved] private Bindable currentRoom { get; set; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index c9fb234ccc..f2dd9a6f25 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -78,8 +78,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Debug.Assert(Room != null); int newCountReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready); + int newCountTotal = Room.Users.Count(u => u.State != MultiplayerUserState.Spectating); - string countText = $"({newCountReady} / {Room.Users.Count} ready)"; + string countText = $"({newCountReady} / {newCountTotal} ready)"; switch (localUser.State) { @@ -88,6 +89,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match updateButtonColour(true); break; + case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: if (Room?.Host?.Equals(localUser) == true) { @@ -103,7 +105,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match break; } - button.Enabled.Value = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value; + bool enableButton = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value; + + // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready. + if (localUser.State == MultiplayerUserState.Spectating) + enableButton &= Room?.Host?.Equals(localUser) == true && newCountReady > 0; + + button.Enabled.Value = enableButton; if (newCountReady != countReady) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs new file mode 100644 index 0000000000..04150902bc --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match +{ + public class MultiplayerSpectateButton : MultiplayerRoomComposite + { + public Action OnSpectateClick + { + set => button.Action = value; + } + + [Resolved] + private OngoingOperationTracker ongoingOperationTracker { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + private IBindable operationInProgress; + + private readonly ButtonWithTrianglesExposed button; + + public MultiplayerSpectateButton() + { + InternalChild = button = new ButtonWithTrianglesExposed + { + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + Enabled = { Value = true }, + }; + } + + [BackgroundDependencyLoader] + private void load() + { + operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy(); + operationInProgress.BindValueChanged(_ => updateState()); + } + + protected override void OnRoomUpdated() + { + base.OnRoomUpdated(); + + updateState(); + } + + private void updateState() + { + var localUser = Client.LocalUser; + + if (localUser == null) + return; + + Debug.Assert(Room != null); + + switch (localUser.State) + { + default: + button.Text = "Spectate"; + button.BackgroundColour = colours.BlueDark; + button.Triangles.ColourDark = colours.BlueDarker; + button.Triangles.ColourLight = colours.Blue; + break; + + case MultiplayerUserState.Spectating: + button.Text = "Stop spectating"; + button.BackgroundColour = colours.Gray4; + button.Triangles.ColourDark = colours.Gray5; + button.Triangles.ColourLight = colours.Gray6; + break; + } + + button.Enabled.Value = Client.Room?.State != MultiplayerRoomState.Closed && !operationInProgress.Value; + } + + private class ButtonWithTrianglesExposed : TriangleButton + { + public new Triangles Triangles => base.Triangles; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index ae22e1fcec..dbac826954 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -15,13 +15,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public class Multiplayer : OnlinePlayScreen { [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } public override void OnResuming(IScreen last) { base.OnResuming(last); - if (client.Room != null) + if (client.Room != null && client.LocalUser?.State != MultiplayerUserState.Spectating) client.ChangeState(MultiplayerUserState.Idle); } @@ -54,12 +54,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Logger.Log($"Polling adjusted (listing: {multiplayerRoomManager.TimeBetweenListingPolls.Value}, selection: {multiplayerRoomManager.TimeBetweenSelectionPolls.Value})"); } - protected override Room CreateNewRoom() - { - var room = new Room { Name = { Value = $"{API.LocalUser}'s awesome room" } }; - room.Category.Value = RoomCategory.Realtime; - return room; - } + protected override Room CreateNewRoom() => + new Room + { + Name = { Value = $"{API.LocalUser}'s awesome room" }, + Category = { Value = RoomCategory.Realtime } + }; protected override string ScreenTitle => "Multiplayer"; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index 0a9a3f680f..4d20652465 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs @@ -18,7 +18,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } public override void Open(Room room) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index f17d97c3fd..3733b85a5e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -4,9 +4,11 @@ using osu.Framework.Allocation; using osu.Framework.Logging; using osu.Framework.Screens; +using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; @@ -15,10 +17,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public class MultiplayerMatchSongSelect : OnlinePlaySongSelect { [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } private LoadingLayer loadingLayer; + /// + /// Construct a new instance of multiplayer song select. + /// + /// An optional initial beatmap selection to perform. + /// An optional initial ruleset selection to perform. + public MultiplayerMatchSongSelect(WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) + { + if (beatmap != null || ruleset != null) + { + Schedule(() => + { + if (beatmap != null) Beatmap.Value = beatmap; + if (ruleset != null) Ruleset.Value = ruleset; + }); + } + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 4fbea4e3be..62ef70ed68 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -12,15 +12,22 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Threading; +using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Online; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osuTK; @@ -29,14 +36,14 @@ using ParticipantsList = osu.Game.Screens.OnlinePlay.Multiplayer.Participants.Pa namespace osu.Game.Screens.OnlinePlay.Multiplayer { [Cached] - public class MultiplayerMatchSubScreen : RoomSubScreen + public class MultiplayerMatchSubScreen : RoomSubScreen, IHandlePresentBeatmap { public override string Title { get; } public override string ShortTitle => "room"; [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } @@ -217,7 +224,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { new MultiplayerMatchFooter { - OnReadyClick = onReadyClick + OnReadyClick = onReadyClick, + OnSpectateClick = onSpectateClick } } }, @@ -279,14 +287,36 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Mods.Value = client.LocalUser.Mods.Select(m => m.ToMod(ruleset)).Concat(SelectedItem.Value.RequiredMods).ToList(); } + [Resolved(canBeNull: true)] + private DialogOverlay dialogOverlay { get; set; } + + private bool exitConfirmed; + public override bool OnBackButton() { - if (client.Room != null && settingsOverlay.State.Value == Visibility.Visible) + if (client.Room == null) + { + // room has not been created yet; exit immediately. + return base.OnBackButton(); + } + + if (settingsOverlay.State.Value == Visibility.Visible) { settingsOverlay.Hide(); return true; } + if (!exitConfirmed && dialogOverlay != null) + { + dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + { + exitConfirmed = true; + this.Exit(); + })); + + return true; + } + return base.OnBackButton(); } @@ -326,10 +356,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.ChangeBeatmapAvailability(availability.NewValue); - // while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap. - if (availability.NewValue != Online.Rooms.BeatmapAvailability.LocallyAvailable() - && client.LocalUser?.State == MultiplayerUserState.Ready) - client.ChangeState(MultiplayerUserState.Idle); + if (availability.NewValue.State != DownloadState.LocallyAvailable) + { + // while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap. + if (client.LocalUser?.State == MultiplayerUserState.Ready) + client.ChangeState(MultiplayerUserState.Idle); + } + else + { + if (client.LocalUser?.State == MultiplayerUserState.Spectating && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) + onLoadRequested(); + } } private void onReadyClick() @@ -337,7 +374,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Debug.Assert(readyClickOperation == null); readyClickOperation = ongoingOperationTracker.BeginOperation(); - if (client.IsHost && client.LocalUser?.State == MultiplayerUserState.Ready) + if (client.IsHost && (client.LocalUser?.State == MultiplayerUserState.Ready || client.LocalUser?.State == MultiplayerUserState.Spectating)) { client.StartMatch() .ContinueWith(t => @@ -364,24 +401,62 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } } + private void onSpectateClick() + { + Debug.Assert(readyClickOperation == null); + readyClickOperation = ongoingOperationTracker.BeginOperation(); + + client.ToggleSpectate().ContinueWith(t => endOperation()); + + void endOperation() + { + readyClickOperation?.Dispose(); + readyClickOperation = null; + } + } + private void onRoomUpdated() { - // user mods may have changed. Scheduler.AddOnce(UpdateMods); } private void onLoadRequested() { - Debug.Assert(client.Room != null); + if (BeatmapAvailability.Value.State != DownloadState.LocallyAvailable) + return; - int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); + // In the case of spectating, IMultiplayerClient.LoadRequested can be fired while the game is still spectating a previous session. + // For now, we want to game to switch to the new game so need to request exiting from the play screen. + if (!ParentScreen.IsCurrentScreen()) + { + ParentScreen.MakeCurrent(); - StartPlay(() => new MultiplayerPlayer(SelectedItem.Value, userIds)); + Schedule(onLoadRequested); + return; + } + + StartPlay(); readyClickOperation?.Dispose(); readyClickOperation = null; } + protected override Screen CreateGameplayScreen() + { + Debug.Assert(client.LocalUser != null); + + int[] userIds = client.CurrentMatchPlayingUserIds.ToArray(); + + switch (client.LocalUser.State) + { + case MultiplayerUserState.Spectating: + return new MultiSpectatorScreen(userIds); + + default: + return new PlayerLoader(() => new MultiplayerPlayer(SelectedItem.Value, userIds)); + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -394,5 +469,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer modSettingChangeTracker?.Dispose(); } + + public void PresentBeatmap(WorkingBeatmap beatmap, RulesetInfo ruleset) + { + if (!this.IsCurrentScreen()) + return; + + if (!client.IsHost) + { + // todo: should handle this when the request queue is implemented. + // if we decide that the presentation should exit the user from the multiplayer game, the PresentBeatmap + // flow may need to change to support an "unable to present" return value. + return; + } + + this.Push(new MultiplayerMatchSongSelect(beatmap, ruleset)); + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index ffcf248575..1bbe49a705 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -11,7 +11,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Scoring; -using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Ranking; @@ -19,8 +18,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay.Multiplayer { - // Todo: The "room" part of PlaylistsPlayer should be split out into an abstract player class to be inherited instead. - public class MultiplayerPlayer : PlaylistsPlayer + public class MultiplayerPlayer : RoomSubmittingPlayer { protected override bool PauseOnFocusLost => false; @@ -28,7 +26,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override bool CheckModsAllowFailure() => false; [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } private IBindable isConnected; @@ -50,7 +48,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { AllowPause = false, AllowRestart = false, - AllowSkippingIntro = false, + AllowSkipping = false, }) { this.userIds = userIds; @@ -63,9 +61,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(ScoreProcessor, userIds), HUDOverlay.Add); HUDOverlay.Add(loadingDisplay = new LoadingLayer(true) { Depth = float.MaxValue }); + } - if (Token == null) - return; // Todo: Somehow handle token retrieval failure. + protected override void LoadAsyncComplete() + { + base.LoadAsyncComplete(); + + if (!ValidForResume) + return; // token retrieval may have failed. client.MatchStarted += onMatchStarted; client.ResultsReady += onResultsReady; @@ -135,15 +138,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void onResultsReady() => resultsReady.SetResult(true); - protected override async Task SubmitScore(Score score) + protected override async Task PrepareScoreForResultsAsync(Score score) { - await base.SubmitScore(score); + await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); - await client.ChangeState(MultiplayerUserState.FinishedPlay); + await client.ChangeState(MultiplayerUserState.FinishedPlay).ConfigureAwait(false); // Await up to 60 seconds for results to become available (6 api request timeouts). // This is arbitrary just to not leave the player in an essentially deadlocked state if any connection issues occur. - await Task.WhenAny(resultsReady.Task, Task.Delay(TimeSpan.FromSeconds(60))); + await Task.WhenAny(resultsReady.Task, Task.Delay(TimeSpan.FromSeconds(60))).ConfigureAwait(false); } protected override ResultsScreen CreateResults(ScoreInfo score) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index 8030107ad8..d334c618f5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -13,7 +13,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected MultiplayerRoom Room => Client.Room; [Resolved] - protected StatefulMultiplayerClient Client { get; private set; } + protected MultiplayerClient Client { get; private set; } protected override void LoadComplete() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs index 1e57847f04..8526196902 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public class MultiplayerRoomManager : RoomManager { [Resolved] - private StatefulMultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } public readonly Bindable TimeBetweenListingPolls = new Bindable(); public readonly Bindable TimeBetweenSelectionPolls = new Bindable(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs index 6c1a55a0eb..7e442c6568 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsListHeader.cs @@ -10,7 +10,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants public class ParticipantsListHeader : OverlinedHeader { [Resolved] - private StatefulMultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } public ParticipantsListHeader() : base("Participants") diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs index c571b51c83..2616b07c1f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs @@ -135,6 +135,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants icon.Colour = colours.BlueLighter; break; + case MultiplayerUserState.Spectating: + text.Text = "spectating"; + icon.Icon = FontAwesome.Solid.Binoculars; + icon.Colour = colours.BlueLight; + break; + default: throw new ArgumentOutOfRangeException(nameof(state), state, null); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs new file mode 100644 index 0000000000..9e1a020eca --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs @@ -0,0 +1,84 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using osu.Framework.Bindables; +using osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A which catches up using rate adjustment. + /// + public class CatchUpSpectatorPlayerClock : ISpectatorPlayerClock + { + /// + /// The catch up rate. + /// + public const double CATCHUP_RATE = 2; + + /// + /// The source clock. + /// + public IFrameBasedClock? Source { get; set; } + + public double CurrentTime { get; private set; } + + public bool IsRunning { get; private set; } + + public void Reset() => CurrentTime = 0; + + public void Start() => IsRunning = true; + + public void Stop() => IsRunning = false; + + public bool Seek(double position) => true; + + public void ResetSpeedAdjustments() + { + } + + public double Rate => IsCatchingUp ? CATCHUP_RATE : 1; + + double IAdjustableClock.Rate + { + get => Rate; + set => throw new NotSupportedException(); + } + + double IClock.Rate => Rate; + + public void ProcessFrame() + { + ElapsedFrameTime = 0; + FramesPerSecond = 0; + + if (Source == null) + return; + + Source.ProcessFrame(); + + if (IsRunning) + { + double elapsedSource = Source.ElapsedFrameTime; + double elapsed = elapsedSource * Rate; + + CurrentTime += elapsed; + ElapsedFrameTime = elapsed; + FramesPerSecond = Source.FramesPerSecond; + } + } + + public double ElapsedFrameTime { get; private set; } + + public double FramesPerSecond { get; private set; } + + public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime }; + + public Bindable WaitingOnFrames { get; } = new Bindable(true); + + public bool IsCatchingUp { get; set; } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs new file mode 100644 index 0000000000..efc12eaaa5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs @@ -0,0 +1,153 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A which synchronises de-synced player clocks through catchup. + /// + public class CatchUpSyncManager : Component, ISyncManager + { + /// + /// The offset from the master clock to which player clocks should remain within to be considered in-sync. + /// + public const double SYNC_TARGET = 16; + + /// + /// The offset from the master clock at which player clocks begin resynchronising. + /// + public const double MAX_SYNC_OFFSET = 50; + + /// + /// The maximum delay to start gameplay, if any (but not all) player clocks are ready. + /// + public const double MAXIMUM_START_DELAY = 15000; + + /// + /// The master clock which is used to control the timing of all player clocks clocks. + /// + public IAdjustableClock MasterClock { get; } + + /// + /// The player clocks. + /// + private readonly List playerClocks = new List(); + + private bool hasStarted; + private double? firstStartAttemptTime; + + public CatchUpSyncManager(IAdjustableClock master) + { + MasterClock = master; + } + + public void AddPlayerClock(ISpectatorPlayerClock clock) => playerClocks.Add(clock); + + public void RemovePlayerClock(ISpectatorPlayerClock clock) => playerClocks.Remove(clock); + + protected override void Update() + { + base.Update(); + + if (!attemptStart()) + { + // Ensure all player clocks are stopped until the start succeeds. + foreach (var clock in playerClocks) + clock.Stop(); + return; + } + + updateCatchup(); + updateMasterClock(); + } + + /// + /// Attempts to start playback. Waits for all player clocks to have available frames for up to milliseconds. + /// + /// Whether playback was started and syncing should occur. + private bool attemptStart() + { + if (hasStarted) + return true; + + if (playerClocks.Count == 0) + return false; + + int readyCount = playerClocks.Count(s => !s.WaitingOnFrames.Value); + + if (readyCount == playerClocks.Count) + return hasStarted = true; + + if (readyCount > 0) + { + firstStartAttemptTime ??= Time.Current; + + if (Time.Current - firstStartAttemptTime > MAXIMUM_START_DELAY) + return hasStarted = true; + } + + return false; + } + + /// + /// Updates the catchup states of all player clocks clocks. + /// + private void updateCatchup() + { + for (int i = 0; i < playerClocks.Count; i++) + { + var clock = playerClocks[i]; + + // How far this player's clock is out of sync, compared to the master clock. + // A negative value means the player is running fast (ahead); a positive value means the player is running behind (catching up). + double timeDelta = MasterClock.CurrentTime - clock.CurrentTime; + + // Check that the player clock isn't too far ahead. + // This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock. + if (timeDelta < -SYNC_TARGET) + { + clock.Stop(); + continue; + } + + // Make sure the player clock is running if it can. + if (!clock.WaitingOnFrames.Value) + clock.Start(); + + if (clock.IsCatchingUp) + { + // Stop the player clock from catching up if it's within the sync target. + if (timeDelta <= SYNC_TARGET) + clock.IsCatchingUp = false; + } + else + { + // Make the player clock start catching up if it's exceeded the maximum allowable sync offset. + if (timeDelta > MAX_SYNC_OFFSET) + clock.IsCatchingUp = true; + } + } + } + + /// + /// Updates the master clock's running state. + /// + private void updateMasterClock() + { + bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp); + + if (MasterClock.IsRunning != anyInSync) + { + if (anyInSync) + MasterClock.Start(); + else + MasterClock.Stop(); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs new file mode 100644 index 0000000000..1a5231e602 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs @@ -0,0 +1,29 @@ +// 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.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A clock which is used by s and managed by an . + /// + public interface ISpectatorPlayerClock : IFrameBasedClock, IAdjustableClock + { + /// + /// Whether this clock is waiting on frames to continue playback. + /// + Bindable WaitingOnFrames { get; } + + /// + /// Whether this clock is resynchronising to the master clock. + /// + bool IsCatchingUp { get; set; } + + /// + /// The source clock + /// + IFrameBasedClock Source { set; } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs new file mode 100644 index 0000000000..bd698108f6 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.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 osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// Manages the synchronisation between one or more s in relation to a master clock. + /// + public interface ISyncManager + { + /// + /// The master clock which player clocks should synchronise to. + /// + IAdjustableClock MasterClock { get; } + + /// + /// Adds an to manage. + /// + /// The to add. + void AddPlayerClock(ISpectatorPlayerClock clock); + + /// + /// Removes an , stopping it from being managed by this . + /// + /// The to remove. + void RemovePlayerClock(ISpectatorPlayerClock clock); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs new file mode 100644 index 0000000000..ab3ead68b5 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs @@ -0,0 +1,72 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Timing; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + public class MultiSpectatorLeaderboard : MultiplayerGameplayLeaderboard + { + public MultiSpectatorLeaderboard([NotNull] ScoreProcessor scoreProcessor, int[] userIds) + : base(scoreProcessor, userIds) + { + } + + public void AddClock(int userId, IClock clock) + { + if (!UserScores.TryGetValue(userId, out var data)) + return; + + ((SpectatingTrackedUserData)data).Clock = clock; + } + + public void RemoveClock(int userId) + { + if (!UserScores.TryGetValue(userId, out var data)) + return; + + ((SpectatingTrackedUserData)data).Clock = null; + } + + protected override TrackedUserData CreateUserData(int userId, ScoreProcessor scoreProcessor) => new SpectatingTrackedUserData(userId, scoreProcessor); + + protected override void Update() + { + base.Update(); + + foreach (var (_, data) in UserScores) + data.UpdateScore(); + } + + private class SpectatingTrackedUserData : TrackedUserData + { + [CanBeNull] + public IClock Clock; + + public SpectatingTrackedUserData(int userId, ScoreProcessor scoreProcessor) + : base(userId, scoreProcessor) + { + } + + public override void UpdateScore() + { + if (Frames.Count == 0) + return; + + if (Clock == null) + return; + + int frameIndex = Frames.BinarySearch(new TimedFrame(Clock.CurrentTime)); + if (frameIndex < 0) + frameIndex = ~frameIndex; + frameIndex = Math.Clamp(frameIndex - 1, 0, Frames.Count - 1); + + SetFrame(Frames[frameIndex]); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs new file mode 100644 index 0000000000..0fe9e01d9d --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A single spectated player within a . + /// + public class MultiSpectatorPlayer : SpectatorPlayer + { + private readonly Bindable waitingOnFrames = new Bindable(true); + private readonly Score score; + private readonly ISpectatorPlayerClock spectatorPlayerClock; + + /// + /// Creates a new . + /// + /// The score containing the player's replay. + /// The clock controlling the gameplay running state. + public MultiSpectatorPlayer([NotNull] Score score, [NotNull] ISpectatorPlayerClock spectatorPlayerClock) + : base(score) + { + this.score = score; + this.spectatorPlayerClock = spectatorPlayerClock; + } + + [BackgroundDependencyLoader] + private void load() + { + spectatorPlayerClock.WaitingOnFrames.BindTo(waitingOnFrames); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + // This is required because the frame stable clock is set to WaitingOnFrames = false for one frame. + waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || score.Replay.Frames.Count == 0; + } + + protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) + => new SpectatorGameplayClockContainer(spectatorPlayerClock); + + private class SpectatorGameplayClockContainer : GameplayClockContainer + { + public SpectatorGameplayClockContainer([NotNull] IClock sourceClock) + : base(sourceClock) + { + } + + protected override void Update() + { + // The player clock's running state is controlled externally, but the local pausing state needs to be updated to stop gameplay. + if (SourceClock.IsRunning) + Start(); + else + Stop(); + + base.Update(); + } + + protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) => new GameplayClock(source); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.cs new file mode 100644 index 0000000000..5a1d28e9c4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayerLoader.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 JetBrains.Annotations; +using osu.Game.Scoring; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// Used to load a single in a . + /// + public class MultiSpectatorPlayerLoader : SpectatorPlayerLoader + { + public MultiSpectatorPlayerLoader([NotNull] Score score, [NotNull] Func createPlayer) + : base(score, createPlayer) + { + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + } + + protected override void LogoExiting(OsuLogo logo) + { + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs new file mode 100644 index 0000000000..277aa5d772 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -0,0 +1,156 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Spectator; +using osu.Game.Screens.Play; +using osu.Game.Screens.Spectate; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A that spectates multiple users in a match. + /// + public class MultiSpectatorScreen : SpectatorScreen + { + // Isolates beatmap/ruleset to this screen. + public override bool DisallowExternalBeatmapRulesetChanges => true; + + /// + /// Whether all spectating players have finished loading. + /// + public bool AllPlayersLoaded => instances.All(p => p?.PlayerLoaded == true); + + [Resolved] + private SpectatorClient spectatorClient { get; set; } + + [Resolved] + private MultiplayerClient multiplayerClient { get; set; } + + private readonly PlayerArea[] instances; + private MasterGameplayClockContainer masterClockContainer; + private ISyncManager syncManager; + private PlayerGrid grid; + private MultiSpectatorLeaderboard leaderboard; + private PlayerArea currentAudioSource; + + /// + /// Creates a new . + /// + /// The players to spectate. + public MultiSpectatorScreen(int[] userIds) + : base(userIds.Take(PlayerGrid.MAX_PLAYERS).ToArray()) + { + instances = new PlayerArea[UserIds.Count]; + } + + [BackgroundDependencyLoader] + private void load() + { + Container leaderboardContainer; + masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0); + + InternalChildren = new[] + { + (Drawable)(syncManager = new CatchUpSyncManager(masterClockContainer)), + masterClockContainer.WithChild(new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + leaderboardContainer = new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X + }, + grid = new PlayerGrid { RelativeSizeAxes = Axes.Both } + } + } + }) + }; + + for (int i = 0; i < UserIds.Count; i++) + { + grid.Add(instances[i] = new PlayerArea(UserIds[i], masterClockContainer.GameplayClock)); + syncManager.AddPlayerClock(instances[i].GameplayClock); + } + + // Todo: This is not quite correct - it should be per-user to adjust for other mod combinations. + var playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); + var scoreProcessor = Ruleset.Value.CreateInstance().CreateScoreProcessor(); + scoreProcessor.ApplyBeatmap(playableBeatmap); + + LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(scoreProcessor, UserIds.ToArray()) + { + Expanded = { Value = true }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, leaderboardContainer.Add); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + masterClockContainer.Stop(); + masterClockContainer.Reset(); + } + + protected override void Update() + { + base.Update(); + + if (!isCandidateAudioSource(currentAudioSource?.GameplayClock)) + { + currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock)) + .OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.MasterClock.CurrentTime)) + .FirstOrDefault(); + + foreach (var instance in instances) + instance.Mute = instance != currentAudioSource; + } + } + + private bool isCandidateAudioSource([CanBeNull] ISpectatorPlayerClock clock) + => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames.Value; + + protected override void OnUserStateChanged(int userId, SpectatorState spectatorState) + { + } + + protected override void StartGameplay(int userId, GameplayState gameplayState) + { + var instance = instances.Single(i => i.UserId == userId); + + instance.LoadScore(gameplayState.Score); + + syncManager.AddPlayerClock(instance.GameplayClock); + leaderboard.AddClock(instance.UserId, instance.GameplayClock); + } + + protected override void EndGameplay(int userId) + { + RemoveUser(userId); + leaderboard.RemoveClock(userId); + } + + public override bool OnBackButton() + { + // On a manual exit, set the player state back to idle. + multiplayerClient.ChangeState(MultiplayerUserState.Idle); + return base.OnBackButton(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs new file mode 100644 index 0000000000..fe79e5db72 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -0,0 +1,144 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// Provides an area for and manages the hierarchy of a spectated player within a . + /// + public class PlayerArea : CompositeDrawable + { + /// + /// Whether a is loaded in the area. + /// + public bool PlayerLoaded => stack?.CurrentScreen is Player; + + /// + /// The user id this corresponds to. + /// + public readonly int UserId; + + /// + /// The used to control the gameplay running state of a loaded . + /// + [NotNull] + public readonly ISpectatorPlayerClock GameplayClock = new CatchUpSpectatorPlayerClock(); + + /// + /// The currently-loaded score. + /// + [CanBeNull] + public Score Score { get; private set; } + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + private readonly BindableDouble volumeAdjustment = new BindableDouble(); + private readonly Container gameplayContent; + private readonly LoadingLayer loadingLayer; + private OsuScreenStack stack; + + public PlayerArea(int userId, IFrameBasedClock masterClock) + { + UserId = userId; + + RelativeSizeAxes = Axes.Both; + Masking = true; + + AudioContainer audioContainer; + InternalChildren = new Drawable[] + { + audioContainer = new AudioContainer + { + RelativeSizeAxes = Axes.Both, + Child = gameplayContent = new DrawSizePreservingFillContainer { RelativeSizeAxes = Axes.Both }, + }, + loadingLayer = new LoadingLayer(true) { State = { Value = Visibility.Visible } } + }; + + audioContainer.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); + + GameplayClock.Source = masterClock; + } + + public void LoadScore([NotNull] Score score) + { + if (Score != null) + throw new InvalidOperationException($"Cannot load a new score on a {nameof(PlayerArea)} that has an existing score."); + + Score = score; + + gameplayContent.Child = new PlayerIsolationContainer(beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.Beatmap), Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) + { + RelativeSizeAxes = Axes.Both, + Child = stack = new OsuScreenStack() + }; + + stack.Push(new MultiSpectatorPlayerLoader(Score, () => new MultiSpectatorPlayer(Score, GameplayClock))); + loadingLayer.Hide(); + } + + private bool mute = true; + + public bool Mute + { + get => mute; + set + { + mute = value; + volumeAdjustment.Value = value ? 0 : 1; + } + } + + // Player interferes with global input, so disable input for now. + public override bool PropagatePositionalInputSubTree => false; + public override bool PropagateNonPositionalInputSubTree => false; + + /// + /// Isolates each player instance from the game-wide ruleset/beatmap/mods (to allow for different players having different settings). + /// + private class PlayerIsolationContainer : Container + { + [Cached] + private readonly Bindable ruleset = new Bindable(); + + [Cached] + private readonly Bindable beatmap = new Bindable(); + + [Cached] + private readonly Bindable> mods = new Bindable>(); + + public PlayerIsolationContainer(WorkingBeatmap beatmap, RulesetInfo ruleset, IReadOnlyList mods) + { + this.beatmap.Value = beatmap; + this.ruleset.Value = ruleset; + this.mods.Value = mods; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(ruleset.BeginLease(false)); + dependencies.CacheAs(beatmap.BeginLease(false)); + dependencies.CacheAs(mods.BeginLease(false)); + return dependencies; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs new file mode 100644 index 0000000000..6638d47dca --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A grid of players playing the multiplayer match. + /// + public partial class PlayerGrid : CompositeDrawable + { + /// + /// A temporary limitation on the number of players, because only layouts up to 16 players are supported for a single screen. + /// Todo: Can be removed in the future with scrolling support + performance improvements. + /// + public const int MAX_PLAYERS = 16; + + private const float player_spacing = 5; + + /// + /// The currently-maximised facade. + /// + public Drawable MaximisedFacade => maximisedFacade; + + private readonly Facade maximisedFacade; + private readonly Container paddingContainer; + private readonly FillFlowContainer facadeContainer; + private readonly Container cellContainer; + + public PlayerGrid() + { + InternalChildren = new Drawable[] + { + paddingContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(player_spacing), + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Child = facadeContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(player_spacing), + } + }, + maximisedFacade = new Facade { RelativeSizeAxes = Axes.Both } + } + }, + cellContainer = new Container { RelativeSizeAxes = Axes.Both } + }; + } + + /// + /// Adds a new cell with content to this grid. + /// + /// The content the cell should contain. + /// If more than cells are added. + public void Add(Drawable content) + { + if (cellContainer.Count == MAX_PLAYERS) + throw new InvalidOperationException($"Only {MAX_PLAYERS} cells are supported."); + + int index = cellContainer.Count; + + var facade = new Facade(); + facadeContainer.Add(facade); + + var cell = new Cell(index, content) { ToggleMaximisationState = toggleMaximisationState }; + cell.SetFacade(facade); + + cellContainer.Add(cell); + } + + /// + /// The content added to this grid. + /// + public IEnumerable Content => cellContainer.OrderBy(c => c.FacadeIndex).Select(c => c.Content); + + // A depth value that gets decremented every time a new instance is maximised in order to reduce underlaps. + private float maximisedInstanceDepth; + + private void toggleMaximisationState(Cell target) + { + // Iterate through all cells to ensure only one is maximised at any time. + foreach (var i in cellContainer.ToList()) + { + if (i == target) + i.IsMaximised = !i.IsMaximised; + else + i.IsMaximised = false; + + if (i.IsMaximised) + { + // Transfer cell to the maximised facade. + i.SetFacade(maximisedFacade); + cellContainer.ChangeChildDepth(i, maximisedInstanceDepth -= 0.001f); + } + else + { + // Transfer cell back to its original facade. + i.SetFacade(facadeContainer[i.FacadeIndex]); + } + } + } + + protected override void Update() + { + base.Update(); + + // Different layouts are used for varying cell counts in order to maximise dimensions. + Vector2 cellsPerDimension; + + switch (facadeContainer.Count) + { + case 1: + cellsPerDimension = Vector2.One; + break; + + case 2: + cellsPerDimension = new Vector2(2, 1); + break; + + case 3: + case 4: + cellsPerDimension = new Vector2(2); + break; + + case 5: + case 6: + cellsPerDimension = new Vector2(3, 2); + break; + + case 7: + case 8: + case 9: + // 3 rows / 3 cols. + cellsPerDimension = new Vector2(3); + break; + + case 10: + case 11: + case 12: + // 3 rows / 4 cols. + cellsPerDimension = new Vector2(4, 3); + break; + + default: + // 4 rows / 4 cols. + cellsPerDimension = new Vector2(4); + break; + } + + // Total inter-cell spacing. + Vector2 totalCellSpacing = player_spacing * (cellsPerDimension - Vector2.One); + + Vector2 fullSize = paddingContainer.ChildSize - totalCellSpacing; + Vector2 cellSize = Vector2.Divide(fullSize, new Vector2(cellsPerDimension.X, cellsPerDimension.Y)); + + foreach (var cell in facadeContainer) + cell.Size = cellSize; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs new file mode 100644 index 0000000000..2df05cb5ed --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Cell.cs @@ -0,0 +1,99 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + public partial class PlayerGrid + { + /// + /// A cell of the grid. Contains the content and tracks to the linked facade. + /// + private class Cell : CompositeDrawable + { + /// + /// The index of the original facade of this cell. + /// + public readonly int FacadeIndex; + + /// + /// The contained content. + /// + public readonly Drawable Content; + + /// + /// An action that toggles the maximisation state of this cell. + /// + public Action ToggleMaximisationState; + + /// + /// Whether this cell is currently maximised. + /// + public bool IsMaximised; + + private Facade facade; + private bool isTracking = true; + + public Cell(int facadeIndex, Drawable content) + { + FacadeIndex = facadeIndex; + + Origin = Anchor.Centre; + InternalChild = Content = content; + } + + protected override void Update() + { + base.Update(); + + if (isTracking) + { + Position = getFinalPosition(); + Size = getFinalSize(); + } + } + + /// + /// Makes this cell track a new facade. + /// + public void SetFacade([NotNull] Facade newFacade) + { + Facade lastFacade = facade; + facade = newFacade; + + if (lastFacade == null || lastFacade == newFacade) + return; + + isTracking = false; + + this.MoveTo(getFinalPosition(), 400, Easing.OutQuint).ResizeTo(getFinalSize(), 400, Easing.OutQuint) + .Then() + .OnComplete(_ => + { + if (facade == newFacade) + isTracking = true; + }); + } + + private Vector2 getFinalPosition() + { + var topLeft = Parent.ToLocalSpace(facade.ToScreenSpace(Vector2.Zero)); + return topLeft + facade.DrawSize / 2; + } + + private Vector2 getFinalSize() => facade.DrawSize; + + protected override bool OnClick(ClickEvent e) + { + ToggleMaximisationState(this); + return true; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Facade.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Facade.cs new file mode 100644 index 0000000000..6b363c6040 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerGrid_Facade.cs @@ -0,0 +1,22 @@ +// 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.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + public partial class PlayerGrid + { + /// + /// A facade of the grid which is used as a dummy object to store the required position/size of cells. + /// + private class Facade : Drawable + { + public Facade() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index 71fd0d5c76..90e499c67f 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -28,7 +28,7 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay { [Cached] - public abstract class OnlinePlayScreen : OsuScreen + public abstract class OnlinePlayScreen : OsuScreen, IHasSubScreenStack { public override bool CursorVisible => (screenStack.CurrentScreen as IOnlinePlaySubScreen)?.CursorVisible ?? true; @@ -355,5 +355,7 @@ namespace osu.Game.Screens.OnlinePlay protected override double TransformDuration => 200; } } + + ScreenStack IHasSubScreenStack.SubScreenStack => screenStack; } } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index f0c77b79bf..3e7e557aad 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -75,9 +75,18 @@ namespace osu.Game.Screens.OnlinePlay Mods.Value = selectedItem?.Value?.RequiredMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty(); FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty(); + Mods.BindValueChanged(onModsChanged); Ruleset.BindValueChanged(onRulesetChanged); } + private void onModsChanged(ValueChangedEvent> mods) + { + FreeMods.Value = FreeMods.Value.Where(checkCompatibleFreeMod).ToList(); + + // Reset the validity delegate to update the overlay's display. + freeModSelectOverlay.IsValidMod = IsValidFreeMod; + } + private void onRulesetChanged(ValueChangedEvent ruleset) { FreeMods.Value = Array.Empty(); @@ -87,15 +96,19 @@ namespace osu.Game.Screens.OnlinePlay { itemSelected = true; - var item = new PlaylistItem(); + var item = new PlaylistItem + { + Beatmap = + { + Value = Beatmap.Value.BeatmapInfo + }, + Ruleset = + { + Value = Ruleset.Value + } + }; - item.Beatmap.Value = Beatmap.Value.BeatmapInfo; - item.Ruleset.Value = Ruleset.Value; - - item.RequiredMods.Clear(); item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy())); - - item.AllowedMods.Clear(); item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.CreateCopy())); SelectItem(item); @@ -155,6 +168,10 @@ namespace osu.Game.Screens.OnlinePlay /// /// The to check. /// Whether is a selectable free-mod. - protected virtual bool IsValidFreeMod(Mod mod) => IsValidMod(mod); + protected virtual bool IsValidFreeMod(Mod mod) => IsValidMod(mod) && checkCompatibleFreeMod(mod); + + private bool checkCompatibleFreeMod(Mod mod) + => Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must not be contained in the required mods. + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); // Mod must be compatible with all the required mods. } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs index 2a1efbc040..5062a296a8 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs @@ -10,6 +10,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -362,7 +363,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Menu.MaxHeight = 100; } - protected override string GenerateItemText(TimeSpan item) => item.Humanize(); + protected override LocalisableString GenerateItemText(TimeSpan item) => item.Humanize(); } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index ddc88261f7..260d4961ff 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -4,13 +4,9 @@ using System; using System.Diagnostics; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -19,36 +15,18 @@ using osu.Game.Screens.Ranking; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsPlayer : Player + public class PlaylistsPlayer : RoomSubmittingPlayer { public Action Exited; - [Resolved(typeof(Room), nameof(Room.RoomID))] - protected Bindable RoomId { get; private set; } - - protected readonly PlaylistItem PlaylistItem; - - protected int? Token { get; private set; } - - [Resolved] - private IAPIProvider api { get; set; } - - [Resolved] - private IBindable ruleset { get; set; } - public PlaylistsPlayer(PlaylistItem playlistItem, PlayerConfiguration configuration = null) - : base(configuration) + : base(playlistItem, configuration) { - PlaylistItem = playlistItem; } [BackgroundDependencyLoader] - private void load() + private void load(IBindable ruleset) { - Token = null; - - bool failed = false; - // Sanity checks to ensure that PlaylistsPlayer matches the settings for the current PlaylistItem if (Beatmap.Value.BeatmapInfo.OnlineBeatmapID != PlaylistItem.Beatmap.Value.OnlineBeatmapID) throw new InvalidOperationException("Current Beatmap does not match PlaylistItem's Beatmap"); @@ -58,29 +36,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists if (!PlaylistItem.RequiredMods.All(m => Mods.Value.Any(m.Equals))) throw new InvalidOperationException("Current Mods do not match PlaylistItem's RequiredMods"); - - var req = new CreateRoomScoreRequest(RoomId.Value ?? 0, PlaylistItem.ID, Game.VersionHash); - req.Success += r => Token = r.ID; - req.Failure += e => - { - failed = true; - - if (string.IsNullOrEmpty(e.Message)) - Logger.Error(e, "Failed to retrieve a score submission token."); - else - Logger.Log($"You are not able to submit a score: {e.Message}", level: LogLevel.Important); - - Schedule(() => - { - ValidForResume = false; - this.Exit(); - }); - }; - - api.Queue(req); - - while (!failed && !Token.HasValue) - Thread.Sleep(1000); } public override bool OnExiting(IScreen next) @@ -106,31 +61,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return score; } - protected override async Task SubmitScore(Score score) - { - await base.SubmitScore(score); - - Debug.Assert(Token != null); - - var tcs = new TaskCompletionSource(); - var request = new SubmitRoomScoreRequest(Token.Value, RoomId.Value ?? 0, PlaylistItem.ID, score.ScoreInfo); - - request.Success += s => - { - score.ScoreInfo.OnlineScoreID = s.ID; - tcs.SetResult(true); - }; - - request.Failure += e => - { - Logger.Error(e, "Failed to submit score"); - tcs.SetResult(false); - }; - - api.Queue(request); - await tcs.Task; - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 6542d01e64..26ee21a2c3 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -13,6 +13,7 @@ using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Users; using osuTK; @@ -218,10 +219,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, new Drawable[] { - new Footer - { - OnStart = onStart, - } + new Footer { OnStart = StartPlay } } }, RowDimensions = new[] @@ -274,7 +272,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, true); } - private void onStart() => StartPlay(() => new PlaylistsPlayer(SelectedItem.Value) + protected override Screen CreateGameplayScreen() => new PlayerLoader(() => new PlaylistsPlayer(SelectedItem.Value) { Exited = () => leaderboard.RefreshScores() }); diff --git a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs index eff06e26ee..fd1150650c 100644 --- a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,6 +15,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Ranking.Expanded; using osuTK; namespace osu.Game.Screens.Play @@ -25,7 +27,7 @@ namespace osu.Game.Screens.Play { private readonly WorkingBeatmap beatmap; private readonly Bindable> mods; - private readonly Drawable facade; + private readonly Drawable logoFacade; private LoadingSpinner loading; public IBindable> Mods => mods; @@ -41,19 +43,24 @@ namespace osu.Game.Screens.Play } } - public BeatmapMetadataDisplay(WorkingBeatmap beatmap, Bindable> mods, Drawable facade) + public BeatmapMetadataDisplay(WorkingBeatmap beatmap, Bindable> mods, Drawable logoFacade) { this.beatmap = beatmap; - this.facade = facade; + this.logoFacade = logoFacade; this.mods = new Bindable>(); this.mods.BindTo(mods); } + private IBindable starDifficulty; + + private FillFlowContainer versionFlow; + private StarRatingDisplay starRatingDisplay; + [BackgroundDependencyLoader] - private void load() + private void load(BeatmapDifficultyCache difficultyCache) { - var metadata = beatmap.BeatmapInfo?.Metadata ?? new BeatmapMetadata(); + var metadata = beatmap.BeatmapInfo.Metadata; AutoSizeAxes = Axes.Both; Children = new Drawable[] @@ -66,14 +73,14 @@ namespace osu.Game.Screens.Play Direction = FillDirection.Vertical, Children = new[] { - facade.With(d => + logoFacade.With(d => { d.Anchor = Anchor.TopCentre; d.Origin = Anchor.TopCentre; }), new OsuSpriteText { - Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), Font = OsuFont.GetFont(size: 36, italics: true), Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, @@ -81,7 +88,7 @@ namespace osu.Game.Screens.Play }, new OsuSpriteText { - Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), Font = OsuFont.GetFont(size: 26, italics: true), Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, @@ -107,16 +114,30 @@ namespace osu.Game.Screens.Play loading = new LoadingLayer(true) } }, - new OsuSpriteText + versionFlow = new FillFlowContainer { - Text = beatmap?.BeatmapInfo?.Version, - Font = OsuFont.GetFont(size: 26, italics: true), - Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, Anchor = Anchor.TopCentre, - Margin = new MarginPadding + Origin = Anchor.TopCentre, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5f), + Margin = new MarginPadding { Bottom = 40 }, + Children = new Drawable[] { - Bottom = 40 - }, + new OsuSpriteText + { + Text = beatmap?.BeatmapInfo?.Version, + Font = OsuFont.GetFont(size: 26, italics: true), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + starRatingDisplay = new StarRatingDisplay(default) + { + Alpha = 0f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + } }, new GridContainer { @@ -159,9 +180,38 @@ namespace osu.Game.Screens.Play } }; + starDifficulty = difficultyCache.GetBindableDifficulty(beatmap.BeatmapInfo); + Loading = true; } + protected override void LoadComplete() + { + base.LoadComplete(); + + if (starDifficulty.Value != null) + { + starRatingDisplay.Current.Value = starDifficulty.Value.Value; + starRatingDisplay.Show(); + } + else + { + starRatingDisplay.Hide(); + + starDifficulty.ValueChanged += d => + { + Debug.Assert(d.NewValue != null); + + starRatingDisplay.Current.Value = d.NewValue.Value; + + versionFlow.AutoSizeDuration = 300; + versionFlow.AutoSizeEasing = Easing.OutQuint; + + starRatingDisplay.FadeIn(300, Easing.InQuint); + }; + } + } + private class MetadataLineLabel : OsuSpriteText { public MetadataLineLabel(string text) diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs index 58eb95b7c6..f8cedddfbe 100644 --- a/osu.Game/Screens/Play/DimmableStoryboard.cs +++ b/osu.Game/Screens/Play/DimmableStoryboard.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; using osu.Game.Storyboards; @@ -19,6 +20,14 @@ namespace osu.Game.Screens.Play private readonly Storyboard storyboard; private DrawableStoryboard drawableStoryboard; + /// + /// Whether the storyboard is considered finished. + /// + /// + /// This is true by default in here, until an actual drawable storyboard is loaded, in which case it'll bind to it. + /// + public IBindable HasStoryboardEnded = new BindableBool(true); + public DimmableStoryboard(Storyboard storyboard) { this.storyboard = storyboard; @@ -49,6 +58,7 @@ namespace osu.Game.Screens.Play return; drawableStoryboard = storyboard.CreateDrawable(); + HasStoryboardEnded.BindTo(drawableStoryboard.HasStoryboardEnded); if (async) LoadComponentAsync(drawableStoryboard, onStoryboardCreated); diff --git a/osu.Game/Screens/Play/GameplayClock.cs b/osu.Game/Screens/Play/GameplayClock.cs index db4b5d300b..54aa395f5f 100644 --- a/osu.Game/Screens/Play/GameplayClock.cs +++ b/osu.Game/Screens/Play/GameplayClock.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.Play /// public class GameplayClock : IFrameBasedClock { - private readonly IFrameBasedClock underlyingClock; + internal readonly IFrameBasedClock UnderlyingClock; public readonly BindableBool IsPaused = new BindableBool(); @@ -30,12 +30,12 @@ namespace osu.Game.Screens.Play public GameplayClock(IFrameBasedClock underlyingClock) { - this.underlyingClock = underlyingClock; + UnderlyingClock = underlyingClock; } - public double CurrentTime => underlyingClock.CurrentTime; + public double CurrentTime => UnderlyingClock.CurrentTime; - public double Rate => underlyingClock.Rate; + public double Rate => UnderlyingClock.Rate; /// /// The rate of gameplay when playback is at 100%. @@ -59,19 +59,19 @@ namespace osu.Game.Screens.Play } } - public bool IsRunning => underlyingClock.IsRunning; + public bool IsRunning => UnderlyingClock.IsRunning; public void ProcessFrame() { // intentionally not updating the underlying clock (handled externally). } - public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; + public double ElapsedFrameTime => UnderlyingClock.ElapsedFrameTime; - public double FramesPerSecond => underlyingClock.FramesPerSecond; + public double FramesPerSecond => UnderlyingClock.FramesPerSecond; - public FrameTimeInfo TimeInfo => underlyingClock.TimeInfo; + public FrameTimeInfo TimeInfo => UnderlyingClock.TimeInfo; - public IClock Source => underlyingClock; + public IClock Source => UnderlyingClock; } } diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index ddbb087962..f791da80c8 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -2,299 +2,189 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading.Tasks; -using osu.Framework; using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; -using osu.Game.Beatmaps; -using osu.Game.Configuration; namespace osu.Game.Screens.Play { /// - /// Encapsulates gameplay timing logic and provides a for children. + /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use. /// - public class GameplayClockContainer : Container + public abstract class GameplayClockContainer : Container, IAdjustableClock { - private readonly WorkingBeatmap beatmap; - - [NotNull] - private ITrack track; + /// + /// The final clock which is exposed to gameplay components. + /// + public GameplayClock GameplayClock { get; private set; } + /// + /// Whether gameplay is paused. + /// public readonly BindableBool IsPaused = new BindableBool(); /// - /// The decoupled clock used for gameplay. Should be used for seeks and clock control. + /// The adjustable source clock used for gameplay. Should be used for seeks and clock control. /// - private readonly DecoupleableInterpolatingFramedClock adjustableClock; - - private readonly double gameplayStartTime; - private readonly bool startAtGameplayStart; - - private readonly double firstHitObjectTime; - - public readonly BindableNumber UserPlaybackRate = new BindableDouble(1) - { - Default = 1, - MinValue = 0.5, - MaxValue = 2, - Precision = 0.1, - }; + protected readonly DecoupleableInterpolatingFramedClock AdjustableSource; /// - /// The final clock which is exposed to underlying components. + /// The source clock. /// - public GameplayClock GameplayClock => localGameplayClock; - - [Cached(typeof(GameplayClock))] - private readonly LocalGameplayClock localGameplayClock; - - private Bindable userAudioOffset; - - private readonly FramedOffsetClock userOffsetClock; - - private readonly FramedOffsetClock platformOffsetClock; + protected IClock SourceClock { get; private set; } /// /// Creates a new . /// - /// The beatmap being played. - /// The suggested time to start gameplay at. - /// - /// Whether should be used regardless of when storyboard events and hitobjects are supposed to start. - /// - public GameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime, bool startAtGameplayStart = false) + /// The source used for timing. + protected GameplayClockContainer(IClock sourceClock) { - this.beatmap = beatmap; - this.gameplayStartTime = gameplayStartTime; - this.startAtGameplayStart = startAtGameplayStart; - track = beatmap.Track; - - firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; + SourceClock = sourceClock; RelativeSizeAxes = Axes.Both; - adjustableClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; + AdjustableSource = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; + IsPaused.BindValueChanged(OnIsPausedChanged); + } - // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. - // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. - platformOffsetClock = new HardwareCorrectionOffsetClock(adjustableClock) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; - - // the final usable gameplay clock with user-set offsets applied. - userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock); - - // the clock to be exposed via DI to children. - localGameplayClock = new LocalGameplayClock(userOffsetClock); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(GameplayClock = CreateGameplayClock(AdjustableSource)); GameplayClock.IsPaused.BindTo(IsPaused); - IsPaused.BindValueChanged(onPauseChanged); + return dependencies; } - private void onPauseChanged(ValueChangedEvent isPaused) - { - if (isPaused.NewValue) - this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => adjustableClock.Stop()); - else - this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); - } - - private double totalOffset => userOffsetClock.Offset + platformOffsetClock.Offset; - /// - /// Duration before gameplay start time required before skip button displays. + /// Starts gameplay. /// - public const double MINIMUM_SKIP_TIME = 1000; - - private readonly BindableDouble pauseFreqAdjust = new BindableDouble(1); - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + public virtual void Start() { - userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); - userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true); + ensureSourceClockSet(); - // sane default provided by ruleset. - double startTime = gameplayStartTime; - - if (!startAtGameplayStart) - { - startTime = Math.Min(0, startTime); - - // if a storyboard is present, it may dictate the appropriate start time by having events in negative time space. - // this is commonly used to display an intro before the audio track start. - double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime; - if (firstStoryboardEvent != null) - startTime = Math.Min(startTime, firstStoryboardEvent.Value); - - // some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available. - // this is not available as an option in the live editor but can still be applied via .osu editing. - if (beatmap.BeatmapInfo.AudioLeadIn > 0) - startTime = Math.Min(startTime, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn); - } - - Seek(startTime); - - adjustableClock.ProcessFrame(); - } - - public void Restart() - { - Task.Run(() => - { - track.Seek(0); - track.Stop(); - - Schedule(() => - { - adjustableClock.ChangeSource(track); - updateRate(); - - if (!IsPaused.Value) - Start(); - }); - }); - } - - public void Start() - { - if (!adjustableClock.IsRunning) + if (!AdjustableSource.IsRunning) { // Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time - // This accounts for the audio clock source potentially taking time to enter a completely stopped state + // This accounts for the clock source potentially taking time to enter a completely stopped state Seek(GameplayClock.CurrentTime); - adjustableClock.Start(); + AdjustableSource.Start(); } IsPaused.Value = false; } - /// - /// Skip forward to the next valid skip point. - /// - public void Skip() - { - if (GameplayClock.CurrentTime > gameplayStartTime - MINIMUM_SKIP_TIME) - return; - - double skipTarget = gameplayStartTime - MINIMUM_SKIP_TIME; - - if (GameplayClock.CurrentTime < 0 && skipTarget > 6000) - // double skip exception for storyboards with very long intros - skipTarget = 0; - - Seek(skipTarget); - } - /// /// Seek to a specific time in gameplay. - /// - /// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track). - /// /// /// The destination time to seek to. - public void Seek(double time) + public virtual void Seek(double time) { - // remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track. - // we may want to consider reversing the application of offsets in the future as it may feel more correct. - adjustableClock.Seek(time - totalOffset); + AdjustableSource.Seek(time); - // manually process frame to ensure GameplayClock is correctly updated after a seek. - userOffsetClock.ProcessFrame(); - } - - public void Stop() - { - IsPaused.Value = true; + // Manually process to make sure the gameplay clock is correctly updated after a seek. + GameplayClock.UnderlyingClock.ProcessFrame(); } /// - /// Changes the backing clock to avoid using the originally provided track. + /// Stops gameplay. /// - public void StopUsingBeatmapClock() - { - removeSourceClockAdjustments(); + public virtual void Stop() => IsPaused.Value = true; - track = new TrackVirtual(track.Length); - adjustableClock.ChangeSource(track); + /// + /// Resets this and the source to an initial state ready for gameplay. + /// + public virtual void Reset() + { + ensureSourceClockSet(); + Seek(0); + + // Manually stop the source in order to not affect the IsPaused state. + AdjustableSource.Stop(); + + if (!IsPaused.Value) + Start(); + } + + /// + /// Changes the source clock. + /// + /// The new source. + protected void ChangeSource(IClock sourceClock) => AdjustableSource.ChangeSource(SourceClock = sourceClock); + + /// + /// Ensures that the is set to , if it hasn't been given a source yet. + /// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode, + /// but not the actual source clock. + /// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor, + /// but it is not yet set on the adjustable source there. + /// + private void ensureSourceClockSet() + { + if (AdjustableSource.Source == null) + ChangeSource(SourceClock); } protected override void Update() { if (!IsPaused.Value) - { - userOffsetClock.ProcessFrame(); - } + GameplayClock.UnderlyingClock.ProcessFrame(); base.Update(); } - private bool speedAdjustmentsApplied; - - private void updateRate() + /// + /// Invoked when the value of is changed to start or stop the clock. + /// + /// Whether the clock should now be paused. + protected virtual void OnIsPausedChanged(ValueChangedEvent isPaused) { - if (speedAdjustmentsApplied) - return; - - track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); - - localGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust); - localGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate); - - speedAdjustmentsApplied = true; + if (isPaused.NewValue) + AdjustableSource.Stop(); + else + AdjustableSource.Start(); } - protected override void Dispose(bool isDisposing) + /// + /// Creates the final which is exposed via DI to be used by gameplay components. + /// + /// + /// Any intermediate clocks such as platform offsets should be applied here. + /// + /// The providing the source time. + /// The final . + protected abstract GameplayClock CreateGameplayClock(IFrameBasedClock source); + + #region IAdjustableClock + + bool IAdjustableClock.Seek(double position) { - base.Dispose(isDisposing); - removeSourceClockAdjustments(); + Seek(position); + return true; } - private void removeSourceClockAdjustments() + void IAdjustableClock.Reset() => Reset(); + + public void ResetSpeedAdjustments() { - if (!speedAdjustmentsApplied) return; - - track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); - - localGameplayClock.MutableNonGameplayAdjustments.Remove(pauseFreqAdjust); - localGameplayClock.MutableNonGameplayAdjustments.Remove(UserPlaybackRate); - - speedAdjustmentsApplied = false; } - private class LocalGameplayClock : GameplayClock + double IAdjustableClock.Rate { - public readonly List> MutableNonGameplayAdjustments = new List>(); - - public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments; - - public LocalGameplayClock(FramedOffsetClock underlyingClock) - : base(underlyingClock) - { - } + get => GameplayClock.Rate; + set => throw new NotSupportedException(); } - private class HardwareCorrectionOffsetClock : FramedOffsetClock - { - // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. - // base implementation already adds offset at 1.0 rate, so we only add the difference from that here. - public override double CurrentTime => base.CurrentTime + Offset * (Rate - 1); + double IClock.Rate => GameplayClock.Rate; - public HardwareCorrectionOffsetClock(IClock source, bool processSource = true) - : base(source, processSource) - { - } - } + public double CurrentTime => GameplayClock.CurrentTime; + + public bool IsRunning => GameplayClock.IsRunning; + + #endregion } } diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index f938839be3..4a28da0dde 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -31,6 +31,8 @@ namespace osu.Game.Screens.Play protected override bool BlockNonPositionalInput => true; + protected override bool BlockScrollInput => false; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; public Action OnRetry; diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs index d5d8ec570a..45ba05e036 100644 --- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -2,23 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osuTK; +using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultAccuracyCounter : PercentageCounter, IAccuracyCounter + public class DefaultAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { - private readonly Vector2 offset = new Vector2(-20, 5); - - public DefaultAccuracyCounter() - { - Origin = Anchor.TopRight; - Anchor = Anchor.TopRight; - } - [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } @@ -27,17 +17,5 @@ namespace osu.Game.Screens.Play.HUD { Colour = colours.BlueLighter; } - - protected override void Update() - { - base.Update(); - - if (hud?.ScoreCounter.Drawable is DefaultScoreCounter score) - { - // for now align with the score counter. eventually this will be user customisable. - Anchor = Anchor.TopLeft; - Position = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.TopLeft) + offset; - } - } } } diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 63e7a88550..c4575c5ad0 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -7,14 +7,13 @@ using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using osuTK; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultComboCounter : RollingCounter, IComboCounter + public class DefaultComboCounter : RollingCounter, ISkinnableDrawable { - private readonly Vector2 offset = new Vector2(20, 5); - [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } @@ -24,17 +23,10 @@ namespace osu.Game.Screens.Play.HUD } [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.BlueLighter; - - protected override void Update() + private void load(OsuColour colours, ScoreProcessor scoreProcessor) { - base.Update(); - - if (hud?.ScoreCounter.Drawable is DefaultScoreCounter score) - { - // for now align with the score counter. eventually this will be user customisable. - Position = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.TopRight) + offset; - } + Colour = colours.BlueLighter; + Current.BindTo(scoreProcessor.Combo); } protected override string FormatCount(int count) diff --git a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs index b550b469e9..ed297f0ffc 100644 --- a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs @@ -13,10 +13,11 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Utils; +using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour + public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour, ISkinnableDrawable { /// /// The base opacity of the glow. @@ -77,7 +78,7 @@ namespace osu.Game.Screens.Play.HUD RelativeSizeAxes = Axes.X; Margin = new MarginPadding { Top = 20 }; - Children = new Drawable[] + InternalChildren = new Drawable[] { new Box { @@ -107,7 +108,7 @@ namespace osu.Game.Screens.Play.HUD GlowColour = colours.BlueDarker; } - public override void Flash(JudgementResult result) => Scheduler.AddOnce(flash); + protected override void Flash(JudgementResult result) => Scheduler.AddOnce(flash); private void flash() { diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 1dcfe2e067..16e3642181 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -4,11 +4,11 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; +using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class DefaultScoreCounter : ScoreCounter + public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableDrawable { public DefaultScoreCounter() : base(6) @@ -24,12 +24,6 @@ namespace osu.Game.Screens.Play.HUD private void load(OsuColour colours) { Colour = colours.BlueLighter; - - // todo: check if default once health display is skinnable - hud?.ShowHealthbar.BindValueChanged(healthBar => - { - this.MoveToY(healthBar.NewValue ? 30 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING); - }, true); } } } diff --git a/osu.Game/Screens/Play/HUD/FailingLayer.cs b/osu.Game/Screens/Play/HUD/FailingLayer.cs index 847b8a53cf..424ee55766 100644 --- a/osu.Game/Screens/Play/HUD/FailingLayer.cs +++ b/osu.Game/Screens/Play/HUD/FailingLayer.cs @@ -39,12 +39,11 @@ namespace osu.Game.Screens.Play.HUD private readonly Container boxes; private Bindable fadePlayfieldWhenHealthLow; - private HealthProcessor healthProcessor; public FailingLayer() { RelativeSizeAxes = Axes.Both; - Children = new Drawable[] + InternalChildren = new Drawable[] { boxes = new Container { @@ -88,18 +87,10 @@ namespace osu.Game.Screens.Play.HUD updateState(); } - public override void BindHealthProcessor(HealthProcessor processor) - { - base.BindHealthProcessor(processor); - - healthProcessor = processor; - updateState(); - } - private void updateState() { // Don't display ever if the ruleset is not using a draining health display. - var showLayer = healthProcessor is DrainingHealthProcessor && fadePlayfieldWhenHealthLow.Value && ShowHealth.Value; + var showLayer = HealthProcessor is DrainingHealthProcessor && fadePlayfieldWhenHealthLow.Value && ShowHealth.Value; this.FadeTo(showLayer ? 1 : 0, fade_time, Easing.OutQuint); } diff --git a/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.cs new file mode 100644 index 0000000000..7a63084812 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/GameplayAccuracyCounter.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.Framework.Allocation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Screens.Play.HUD +{ + public abstract class GameplayAccuracyCounter : PercentageCounter + { + [BackgroundDependencyLoader] + private void load(ScoreProcessor scoreProcessor) + { + Current.BindTo(scoreProcessor.Accuracy); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs similarity index 56% rename from osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs rename to osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs index b46f5684b1..e09630d2c4 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs @@ -5,27 +5,22 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; namespace osu.Game.Screens.Play.HUD { - public class SkinnableScoreCounter : SkinnableDrawable, IScoreCounter + public abstract class GameplayScoreCounter : ScoreCounter { - public Bindable Current { get; } = new Bindable(); - private Bindable scoreDisplayMode; - public Bindable RequiredDisplayDigits { get; } = new Bindable(); - - public SkinnableScoreCounter() - : base(new HUDSkinComponent(HUDSkinComponents.ScoreCounter), _ => new DefaultScoreCounter()) + protected GameplayScoreCounter(int leading = 0, bool useCommaSeparator = false) + : base(leading, useCommaSeparator) { - CentreComponent = false; } [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + private void load(OsuConfigManager config, ScoreProcessor scoreProcessor) { scoreDisplayMode = config.GetBindable(OsuSetting.ScoreDisplayMode); scoreDisplayMode.BindValueChanged(scoreMode => @@ -44,18 +39,8 @@ namespace osu.Game.Screens.Play.HUD throw new ArgumentOutOfRangeException(nameof(scoreMode)); } }, true); - } - private IScoreCounter skinnedCounter; - - protected override void SkinChanged(ISkinSource skin, bool allowFallback) - { - base.SkinChanged(skin, allowFallback); - - skinnedCounter = Drawable as IScoreCounter; - - skinnedCounter?.Current.BindTo(Current); - skinnedCounter?.RequiredDisplayDigits.BindTo(RequiredDisplayDigits); + Current.BindTo(scoreProcessor.TotalScore); } } } diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index 5c43e00192..1f0fafa636 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -1,7 +1,9 @@ // 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.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -11,26 +13,54 @@ namespace osu.Game.Screens.Play.HUD { /// /// A container for components displaying the current player health. - /// Gets bound automatically to the when inserted to hierarchy. + /// Gets bound automatically to the when inserted to hierarchy. /// - public abstract class HealthDisplay : Container, IHealthDisplay + public abstract class HealthDisplay : CompositeDrawable { + private readonly Bindable showHealthbar = new Bindable(true); + + [Resolved] + protected HealthProcessor HealthProcessor { get; private set; } + public Bindable Current { get; } = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; - public virtual void Flash(JudgementResult result) + protected virtual void Flash(JudgementResult result) { } - /// - /// Bind the tracked fields of to this health display. - /// - public virtual void BindHealthProcessor(HealthProcessor processor) + [Resolved(canBeNull: true)] + private HUDOverlay hudOverlay { get; set; } + + protected override void LoadComplete() { - Current.BindTo(processor.Health); + base.LoadComplete(); + + Current.BindTo(HealthProcessor.Health); + HealthProcessor.NewJudgement += onNewJudgement; + + if (hudOverlay != null) + showHealthbar.BindTo(hudOverlay.ShowHealthbar); + + // this probably shouldn't be operating on `this.` + showHealthbar.BindValueChanged(healthBar => this.FadeTo(healthBar.NewValue ? 1 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING), true); + } + + private void onNewJudgement(JudgementResult judgement) + { + if (judgement.IsHit && judgement.Type != HitResult.IgnoreHit) + Flash(judgement); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (HealthProcessor != null) + HealthProcessor.NewJudgement -= onNewJudgement; } } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs b/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs deleted file mode 100644 index 37d10a5320..0000000000 --- a/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs +++ /dev/null @@ -1,151 +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.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Configuration; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Scoring; -using osu.Game.Screens.Play.HUD.HitErrorMeters; - -namespace osu.Game.Screens.Play.HUD -{ - public class HitErrorDisplay : Container - { - private const int fade_duration = 200; - private const int margin = 10; - - private readonly Bindable type = new Bindable(); - - private readonly HitWindows hitWindows; - - private readonly ScoreProcessor processor; - - public HitErrorDisplay(ScoreProcessor processor, HitWindows hitWindows) - { - this.processor = processor; - this.hitWindows = hitWindows; - - RelativeSizeAxes = Axes.Both; - - if (processor != null) - processor.NewJudgement += onNewJudgement; - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - config.BindWith(OsuSetting.ScoreMeter, type); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - type.BindValueChanged(typeChanged, true); - } - - private void onNewJudgement(JudgementResult result) - { - if (result.HitObject.HitWindows.WindowFor(HitResult.Miss) == 0) - return; - - foreach (var c in Children) - c.OnNewJudgement(result); - } - - private void typeChanged(ValueChangedEvent type) - { - Children.ForEach(c => c.FadeOut(fade_duration, Easing.OutQuint)); - - if (hitWindows == null) - return; - - switch (type.NewValue) - { - case ScoreMeterType.HitErrorBoth: - createBar(Anchor.CentreLeft); - createBar(Anchor.CentreRight); - break; - - case ScoreMeterType.HitErrorLeft: - createBar(Anchor.CentreLeft); - break; - - case ScoreMeterType.HitErrorRight: - createBar(Anchor.CentreRight); - break; - - case ScoreMeterType.HitErrorBottom: - createBar(Anchor.BottomCentre); - break; - - case ScoreMeterType.ColourBoth: - createColour(Anchor.CentreLeft); - createColour(Anchor.CentreRight); - break; - - case ScoreMeterType.ColourLeft: - createColour(Anchor.CentreLeft); - break; - - case ScoreMeterType.ColourRight: - createColour(Anchor.CentreRight); - break; - - case ScoreMeterType.ColourBottom: - createColour(Anchor.BottomCentre); - break; - } - } - - private void createBar(Anchor anchor) - { - bool rightAligned = (anchor & Anchor.x2) > 0; - bool bottomAligned = (anchor & Anchor.y2) > 0; - - var display = new BarHitErrorMeter(hitWindows, rightAligned) - { - Margin = new MarginPadding(margin), - Anchor = anchor, - Origin = bottomAligned ? Anchor.CentreLeft : anchor, - Alpha = 0, - Rotation = bottomAligned ? 270 : 0 - }; - - completeDisplayLoading(display); - } - - private void createColour(Anchor anchor) - { - bool bottomAligned = (anchor & Anchor.y2) > 0; - - var display = new ColourHitErrorMeter(hitWindows) - { - Margin = new MarginPadding(margin), - Anchor = anchor, - Origin = bottomAligned ? Anchor.CentreLeft : anchor, - Alpha = 0, - Rotation = bottomAligned ? 270 : 0 - }; - - completeDisplayLoading(display); - } - - private void completeDisplayLoading(HitErrorMeter display) - { - Add(display); - display.FadeInFromZero(fade_duration, Easing.OutQuint); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (processor != null) - processor.NewJudgement -= onNewJudgement; - } - } -} diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 89f135de7f..5d0263772d 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -20,8 +20,6 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { public class BarHitErrorMeter : HitErrorMeter { - private readonly Anchor alignment; - private const int arrow_move_duration = 400; private const int judgement_line_width = 6; @@ -43,11 +41,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private double maxHitWindow; - public BarHitErrorMeter(HitWindows hitWindows, bool rightAligned = false) - : base(hitWindows) + public BarHitErrorMeter() { - alignment = rightAligned ? Anchor.x0 : Anchor.x2; - AutoSizeAxes = Axes.Both; } @@ -63,33 +58,42 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Margin = new MarginPadding(2), Children = new Drawable[] { - judgementsContainer = new Container + new Container { - Anchor = Anchor.y1 | alignment, - Origin = Anchor.y1 | alignment, - Width = judgement_line_width, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = chevron_size, RelativeSizeAxes = Axes.Y, + Child = arrow = new SpriteIcon + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Y, + Y = 0.5f, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(chevron_size), + } }, colourBars = new Container { Width = bar_width, RelativeSizeAxes = Axes.Y, - Anchor = Anchor.y1 | alignment, - Origin = Anchor.y1 | alignment, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, Children = new Drawable[] { colourBarsEarly = new Container { - Anchor = Anchor.y1 | alignment, - Origin = alignment, + Anchor = Anchor.CentreLeft, + Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Both, Height = 0.5f, Scale = new Vector2(1, -1), }, colourBarsLate = new Container { - Anchor = Anchor.y1 | alignment, - Origin = alignment, + Anchor = Anchor.CentreLeft, + Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Both, Height = 0.5f, }, @@ -115,21 +119,12 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } } }, - new Container + judgementsContainer = new Container { - Anchor = Anchor.y1 | alignment, - Origin = Anchor.y1 | alignment, - Width = chevron_size, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = judgement_line_width, RelativeSizeAxes = Axes.Y, - Child = arrow = new SpriteIcon - { - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, - RelativePositionAxes = Axes.Y, - Y = 0.5f, - Icon = alignment == Anchor.x2 ? FontAwesome.Solid.ChevronRight : FontAwesome.Solid.ChevronLeft, - Size = new Vector2(chevron_size), - } }, } }; @@ -152,19 +147,22 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { var windows = HitWindows.GetAllAvailableWindows().ToArray(); - maxHitWindow = windows.First().length; + // max to avoid div-by-zero. + maxHitWindow = Math.Max(1, windows.First().length); for (var i = 0; i < windows.Length; i++) { var (result, length) = windows[i]; - colourBarsEarly.Add(createColourBar(result, (float)(length / maxHitWindow), i == 0)); - colourBarsLate.Add(createColourBar(result, (float)(length / maxHitWindow), i == 0)); + var hitWindow = (float)(length / maxHitWindow); + + colourBarsEarly.Add(createColourBar(result, hitWindow, i == 0)); + colourBarsLate.Add(createColourBar(result, hitWindow, i == 0)); } // a little nub to mark the centre point. var centre = createColourBar(windows.Last().result, 0.01f); - centre.Anchor = centre.Origin = Anchor.y1 | (alignment == Anchor.x2 ? Anchor.x0 : Anchor.x2); + centre.Anchor = centre.Origin = Anchor.CentreLeft; centre.Width = 2.5f; colourBars.Add(centre); @@ -214,7 +212,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private const int max_concurrent_judgements = 50; - public override void OnNewJudgement(JudgementResult judgement) + protected override void OnNewJudgement(JudgementResult judgement) { if (!judgement.IsHit) return; @@ -236,8 +234,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters judgementsContainer.Add(new JudgementLine { Y = getRelativeJudgementPosition(judgement.TimeOffset), - Anchor = alignment == Anchor.x2 ? Anchor.x0 : Anchor.x2, - Origin = Anchor.y1 | (alignment == Anchor.x2 ? Anchor.x0 : Anchor.x2), + Origin = Anchor.CentreLeft, }); arrow.MoveToY( diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs index 657235bfd4..e9ccbcdae2 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -7,7 +7,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Scoring; using osuTK; using osuTK.Graphics; @@ -19,14 +18,13 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private readonly JudgementFlow judgementsFlow; - public ColourHitErrorMeter(HitWindows hitWindows) - : base(hitWindows) + public ColourHitErrorMeter() { AutoSizeAxes = Axes.Both; InternalChild = judgementsFlow = new JudgementFlow(); } - public override void OnNewJudgement(JudgementResult judgement) => judgementsFlow.Push(GetColourForHitResult(HitWindows.ResultFor(judgement.TimeOffset))); + protected override void OnNewJudgement(JudgementResult judgement) => judgementsFlow.Push(GetColourForHitResult(judgement.Type)); private class JudgementFlow : FillFlowContainer { diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs index b3edfdedec..b0f9928b13 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -6,23 +6,44 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD.HitErrorMeters { - public abstract class HitErrorMeter : CompositeDrawable + public abstract class HitErrorMeter : CompositeDrawable, ISkinnableDrawable { - protected readonly HitWindows HitWindows; + protected HitWindows HitWindows { get; private set; } + + [Resolved] + private ScoreProcessor processor { get; set; } [Resolved] private OsuColour colours { get; set; } - protected HitErrorMeter(HitWindows hitWindows) + [BackgroundDependencyLoader(true)] + private void load(DrawableRuleset drawableRuleset) { - HitWindows = hitWindows; + HitWindows = drawableRuleset?.FirstAvailableHitWindows ?? HitWindows.Empty; } - public abstract void OnNewJudgement(JudgementResult judgement); + protected override void LoadComplete() + { + base.LoadComplete(); + + processor.NewJudgement += onNewJudgement; + } + + private void onNewJudgement(JudgementResult result) + { + if (result.HitObject.HitWindows?.WindowFor(HitResult.Miss) == 0) + return; + + OnNewJudgement(result); + } + + protected abstract void OnNewJudgement(JudgementResult judgement); protected Color4 GetColourForHitResult(HitResult result) { @@ -47,5 +68,13 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters return colours.BlueLight; } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (processor != null) + processor.NewJudgement -= onNewJudgement; + } } } diff --git a/osu.Game/Screens/Play/HUD/IAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/IAccuracyCounter.cs deleted file mode 100644 index 0199250a08..0000000000 --- a/osu.Game/Screens/Play/HUD/IAccuracyCounter.cs +++ /dev/null @@ -1,19 +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.Framework.Graphics; - -namespace osu.Game.Screens.Play.HUD -{ - /// - /// An interface providing a set of methods to update a accuracy counter. - /// - public interface IAccuracyCounter : IDrawable - { - /// - /// The current accuracy to be displayed. - /// - Bindable Current { get; } - } -} diff --git a/osu.Game/Screens/Play/HUD/IHealthDisplay.cs b/osu.Game/Screens/Play/HUD/IHealthDisplay.cs deleted file mode 100644 index b1a64bd844..0000000000 --- a/osu.Game/Screens/Play/HUD/IHealthDisplay.cs +++ /dev/null @@ -1,26 +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.Framework.Graphics; -using osu.Game.Rulesets.Judgements; - -namespace osu.Game.Screens.Play.HUD -{ - /// - /// An interface providing a set of methods to update a health display. - /// - public interface IHealthDisplay : IDrawable - { - /// - /// The current health to be displayed. - /// - Bindable Current { get; } - - /// - /// Flash the display for a specified result type. - /// - /// The result type. - void Flash(JudgementResult result); - } -} diff --git a/osu.Game/Screens/Play/HUD/IScoreCounter.cs b/osu.Game/Screens/Play/HUD/IScoreCounter.cs deleted file mode 100644 index 7f5e81d5ef..0000000000 --- a/osu.Game/Screens/Play/HUD/IScoreCounter.cs +++ /dev/null @@ -1,25 +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.Framework.Graphics; - -namespace osu.Game.Screens.Play.HUD -{ - /// - /// An interface providing a set of methods to update a score counter. - /// - public interface IScoreCounter : IDrawable - { - /// - /// The current score to be displayed. - /// - Bindable Current { get; } - - /// - /// The number of digits required to display most sane scores. - /// This may be exceeded in very rare cases, but is useful to pad or space the display to avoid it jumping around. - /// - Bindable RequiredDisplayDigits { get; } - } -} diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index 4784bca7dd..1737634e31 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -6,7 +6,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osuTK; @@ -15,9 +15,9 @@ namespace osu.Game.Screens.Play.HUD /// /// Uses the 'x' symbol and has a pop-out effect while rolling over. /// - public class LegacyComboCounter : CompositeDrawable, IComboCounter + public class LegacyComboCounter : CompositeDrawable, ISkinnableDrawable { - public Bindable Current { get; } = new BindableInt { MinValue = 0, }; + public Bindable Current { get; } = new BindableInt { MinValue = 0 }; private uint scheduledPopOutCurrentId; @@ -32,9 +32,9 @@ namespace osu.Game.Screens.Play.HUD /// private const double rolling_duration = 20; - private Drawable popOutCount; + private readonly Drawable popOutCount; - private Drawable displayedCountSpriteText; + private readonly Drawable displayedCountSpriteText; private int previousValue; @@ -45,6 +45,20 @@ namespace osu.Game.Screens.Play.HUD [Resolved] private ISkinSource skin { get; set; } + private readonly Container counterContainer; + + /// + /// Hides the combo counter internally without affecting its . + /// + /// + /// This is used for rulesets that provide their own combo counter and don't want this HUD one to be visible, + /// without potentially affecting the user's selected skin. + /// + public bool HiddenByRulesetImplementation + { + set => counterContainer.Alpha = value ? 1 : 0; + } + public LegacyComboCounter() { AutoSizeAxes = Axes.Both; @@ -55,6 +69,25 @@ namespace osu.Game.Screens.Play.HUD Margin = new MarginPadding(10); Scale = new Vector2(1.2f); + + InternalChild = counterContainer = new Container + { + AutoSizeAxes = Axes.Both, + AlwaysPresent = true, + Children = new[] + { + popOutCount = new LegacySpriteText(LegacyFont.Combo) + { + Alpha = 0, + Margin = new MarginPadding(0.05f), + Blending = BlendingParameters.Additive, + }, + displayedCountSpriteText = new LegacySpriteText(LegacyFont.Combo) + { + Alpha = 0, + }, + } + }; } /// @@ -80,22 +113,9 @@ namespace osu.Game.Screens.Play.HUD } [BackgroundDependencyLoader] - private void load() + private void load(ScoreProcessor scoreProcessor) { - InternalChildren = new[] - { - displayedCountSpriteText = createSpriteText().With(s => - { - s.Alpha = 0; - }), - popOutCount = createSpriteText().With(s => - { - s.Alpha = 0; - s.Margin = new MarginPadding(0.05f); - }) - }; - - Current.ValueChanged += combo => updateCount(combo.NewValue == 0); + Current.BindTo(scoreProcessor.Combo); } protected override void LoadComplete() @@ -104,12 +124,14 @@ namespace osu.Game.Screens.Play.HUD ((IHasText)displayedCountSpriteText).Text = formatCount(Current.Value); + counterContainer.Anchor = Anchor; + counterContainer.Origin = Origin; displayedCountSpriteText.Anchor = Anchor; displayedCountSpriteText.Origin = Origin; - popOutCount.Origin = Origin; popOutCount.Anchor = Anchor; + popOutCount.Origin = Origin; - updateCount(false); + Current.BindValueChanged(combo => updateCount(combo.NewValue == 0), true); } private void updateCount(bool rolling) @@ -246,7 +268,5 @@ namespace osu.Game.Screens.Play.HUD double difference = currentValue > newValue ? currentValue - newValue : newValue - currentValue; return difference * rolling_duration; } - - private OsuSpriteText createSpriteText() => (OsuSpriteText)skin.GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ComboText)); } } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index a3d27c4e71..c3bfe19b29 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -1,10 +1,10 @@ // 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.Collections.Specialized; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Configuration; @@ -19,22 +19,20 @@ namespace osu.Game.Screens.Play.HUD [LongRunningLoad] public class MultiplayerGameplayLeaderboard : GameplayLeaderboard { - private readonly ScoreProcessor scoreProcessor; - - private readonly Dictionary userScores = new Dictionary(); + protected readonly Dictionary UserScores = new Dictionary(); [Resolved] - private SpectatorStreamingClient streamingClient { get; set; } + private SpectatorClient spectatorClient { get; set; } [Resolved] - private StatefulMultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } [Resolved] private UserLookupCache userLookupCache { get; set; } - private Bindable scoringMode; - + private readonly ScoreProcessor scoreProcessor; private readonly BindableList playingUsers; + private Bindable scoringMode; /// /// Construct a new leaderboard. @@ -53,26 +51,24 @@ namespace osu.Game.Screens.Play.HUD [BackgroundDependencyLoader] private void load(OsuConfigManager config, IAPIProvider api) { + scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); + foreach (var userId in playingUsers) { - streamingClient.WatchUser(userId); - // probably won't be required in the final implementation. var resolvedUser = userLookupCache.GetUserAsync(userId).Result; - var trackedUser = new TrackedUserData(); + var trackedUser = CreateUserData(userId, scoreProcessor); + trackedUser.ScoringMode.BindTo(scoringMode); - userScores[userId] = trackedUser; var leaderboardScore = AddPlayer(resolvedUser, resolvedUser?.Id == api.LocalUser.Value.Id); + leaderboardScore.Accuracy.BindTo(trackedUser.Accuracy); + leaderboardScore.TotalScore.BindTo(trackedUser.Score); + leaderboardScore.Combo.BindTo(trackedUser.CurrentCombo); + leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit); - ((IBindable)leaderboardScore.Accuracy).BindTo(trackedUser.Accuracy); - ((IBindable)leaderboardScore.TotalScore).BindTo(trackedUser.Score); - ((IBindable)leaderboardScore.Combo).BindTo(trackedUser.CurrentCombo); - ((IBindable)leaderboardScore.HasQuit).BindTo(trackedUser.UserQuit); + UserScores[userId] = trackedUser; } - - scoringMode = config.GetBindable(OsuSetting.ScoreDisplayMode); - scoringMode.BindValueChanged(updateAllScores, true); } protected override void LoadComplete() @@ -82,6 +78,8 @@ namespace osu.Game.Screens.Play.HUD // BindableList handles binding in a really bad way (Clear then AddRange) so we need to do this manually.. foreach (int userId in playingUsers) { + spectatorClient.WatchUser(userId); + if (!multiplayerClient.CurrentMatchPlayingUserIds.Contains(userId)) usersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { userId })); } @@ -90,7 +88,7 @@ namespace osu.Game.Screens.Play.HUD playingUsers.BindCollectionChanged(usersChanged); // this leaderboard should be guaranteed to be completely loaded before the gameplay starts (is a prerequisite in MultiplayerPlayer). - streamingClient.OnNewFrames += handleIncomingFrames; + spectatorClient.OnNewFrames += handleIncomingFrames; } private void usersChanged(object sender, NotifyCollectionChangedEventArgs e) @@ -100,9 +98,9 @@ namespace osu.Game.Screens.Play.HUD case NotifyCollectionChangedAction.Remove: foreach (var userId in e.OldItems.OfType()) { - streamingClient.StopWatchingUser(userId); + spectatorClient.StopWatchingUser(userId); - if (userScores.TryGetValue(userId, out var trackedData)) + if (UserScores.TryGetValue(userId, out var trackedData)) trackedData.MarkUserQuit(); } @@ -110,68 +108,91 @@ namespace osu.Game.Screens.Play.HUD } } - private void updateAllScores(ValueChangedEvent mode) + private void handleIncomingFrames(int userId, FrameDataBundle bundle) => Schedule(() => { - foreach (var trackedData in userScores.Values) - trackedData.UpdateScore(scoreProcessor, mode.NewValue); - } + if (!UserScores.TryGetValue(userId, out var trackedData)) + return; - private void handleIncomingFrames(int userId, FrameDataBundle bundle) - { - if (userScores.TryGetValue(userId, out var trackedData)) - { - trackedData.LastHeader = bundle.Header; - trackedData.UpdateScore(scoreProcessor, scoringMode.Value); - } - } + trackedData.Frames.Add(new TimedFrame(bundle.Frames.First().Time, bundle.Header)); + trackedData.UpdateScore(); + }); + + protected virtual TrackedUserData CreateUserData(int userId, ScoreProcessor scoreProcessor) => new TrackedUserData(userId, scoreProcessor); protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (streamingClient != null) + if (spectatorClient != null) { foreach (var user in playingUsers) { - streamingClient.StopWatchingUser(user); + spectatorClient.StopWatchingUser(user); } - streamingClient.OnNewFrames -= handleIncomingFrames; + spectatorClient.OnNewFrames -= handleIncomingFrames; } } - private class TrackedUserData + protected class TrackedUserData { - public IBindableNumber Score => score; + public readonly int UserId; + public readonly ScoreProcessor ScoreProcessor; - private readonly BindableDouble score = new BindableDouble(); + public readonly BindableDouble Score = new BindableDouble(); + public readonly BindableDouble Accuracy = new BindableDouble(1); + public readonly BindableInt CurrentCombo = new BindableInt(); + public readonly BindableBool UserQuit = new BindableBool(); - public IBindableNumber Accuracy => accuracy; + public readonly IBindable ScoringMode = new Bindable(); - private readonly BindableDouble accuracy = new BindableDouble(1); + public readonly List Frames = new List(); - public IBindableNumber CurrentCombo => currentCombo; - - private readonly BindableInt currentCombo = new BindableInt(); - - public IBindable UserQuit => userQuit; - - private readonly BindableBool userQuit = new BindableBool(); - - [CanBeNull] - public FrameHeader LastHeader; - - public void MarkUserQuit() => userQuit.Value = true; - - public void UpdateScore(ScoreProcessor processor, ScoringMode mode) + public TrackedUserData(int userId, ScoreProcessor scoreProcessor) { - if (LastHeader == null) + UserId = userId; + ScoreProcessor = scoreProcessor; + + ScoringMode.BindValueChanged(_ => UpdateScore()); + } + + public void MarkUserQuit() => UserQuit.Value = true; + + public virtual void UpdateScore() + { + if (Frames.Count == 0) return; - score.Value = processor.GetImmediateScore(mode, LastHeader.MaxCombo, LastHeader.Statistics); - accuracy.Value = LastHeader.Accuracy; - currentCombo.Value = LastHeader.Combo; + SetFrame(Frames.Last()); } + + protected void SetFrame(TimedFrame frame) + { + var header = frame.Header; + + Score.Value = ScoreProcessor.GetImmediateScore(ScoringMode.Value, header.MaxCombo, header.Statistics); + Accuracy.Value = header.Accuracy; + CurrentCombo.Value = header.Combo; + } + } + + protected class TimedFrame : IComparable + { + public readonly double Time; + public readonly FrameHeader Header; + + public TimedFrame(double time) + { + Time = time; + } + + public TimedFrame(double time, FrameHeader header) + { + Time = time; + Header = header; + } + + public int CompareTo(TimedFrame other) => Time.CompareTo(other.Time); } } } diff --git a/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs deleted file mode 100644 index 76c9c30813..0000000000 --- a/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs +++ /dev/null @@ -1,29 +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.Skinning; - -namespace osu.Game.Screens.Play.HUD -{ - public class SkinnableAccuracyCounter : SkinnableDrawable, IAccuracyCounter - { - public Bindable Current { get; } = new Bindable(); - - public SkinnableAccuracyCounter() - : base(new HUDSkinComponent(HUDSkinComponents.AccuracyCounter), _ => new DefaultAccuracyCounter()) - { - CentreComponent = false; - } - - private IAccuracyCounter skinnedCounter; - - protected override void SkinChanged(ISkinSource skin, bool allowFallback) - { - base.SkinChanged(skin, allowFallback); - - skinnedCounter = Drawable as IAccuracyCounter; - skinnedCounter?.Current.BindTo(Current); - } - } -} diff --git a/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs deleted file mode 100644 index c04c50141a..0000000000 --- a/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs +++ /dev/null @@ -1,29 +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.Skinning; - -namespace osu.Game.Screens.Play.HUD -{ - public class SkinnableComboCounter : SkinnableDrawable, IComboCounter - { - public Bindable Current { get; } = new Bindable(); - - public SkinnableComboCounter() - : base(new HUDSkinComponent(HUDSkinComponents.ComboCounter), skinComponent => new DefaultComboCounter()) - { - CentreComponent = false; - } - - private IComboCounter skinnedCounter; - - protected override void SkinChanged(ISkinSource skin, bool allowFallback) - { - base.SkinChanged(skin, allowFallback); - - skinnedCounter = Drawable as IComboCounter; - skinnedCounter?.Current.BindTo(Current); - } - } -} diff --git a/osu.Game/Screens/Play/HUD/SkinnableHealthDisplay.cs b/osu.Game/Screens/Play/HUD/SkinnableHealthDisplay.cs deleted file mode 100644 index 1f91f5e50f..0000000000 --- a/osu.Game/Screens/Play/HUD/SkinnableHealthDisplay.cs +++ /dev/null @@ -1,50 +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 osu.Framework.Bindables; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; - -namespace osu.Game.Screens.Play.HUD -{ - public class SkinnableHealthDisplay : SkinnableDrawable, IHealthDisplay - { - public Bindable Current { get; } = new BindableDouble(1) - { - MinValue = 0, - MaxValue = 1 - }; - - public void Flash(JudgementResult result) => skinnedCounter?.Flash(result); - - private HealthProcessor processor; - - public void BindHealthProcessor(HealthProcessor processor) - { - if (this.processor != null) - throw new InvalidOperationException("Can't bind to a processor more than once"); - - this.processor = processor; - - Current.BindTo(processor.Health); - } - - public SkinnableHealthDisplay() - : base(new HUDSkinComponent(HUDSkinComponents.HealthDisplay), _ => new DefaultHealthDisplay()) - { - CentreComponent = false; - } - - private IHealthDisplay skinnedCounter; - - protected override void SkinChanged(ISkinSource skin, bool allowFallback) - { - base.SkinChanged(skin, allowFallback); - - skinnedCounter = Drawable as IHealthDisplay; - skinnedCounter?.Current.BindTo(Current); - } - } -} diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs new file mode 100644 index 0000000000..e08044b14c --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Extensions; +using osu.Game.IO.Serialization; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// Serialised information governing custom changes to an . + /// + [Serializable] + public class SkinnableInfo : IJsonSerializable + { + public Type Type { get; set; } + + public Vector2 Position { get; set; } + + public float Rotation { get; set; } + + public Vector2 Scale { get; set; } + + public Anchor Anchor { get; set; } + + public Anchor Origin { get; set; } + + public List Children { get; } = new List(); + + [JsonConstructor] + public SkinnableInfo() + { + } + + /// + /// Construct a new instance populating all attributes from the provided drawable. + /// + /// The drawable which attributes should be sourced from. + public SkinnableInfo(Drawable component) + { + Type = component.GetType(); + + Position = component.Position; + Rotation = component.Rotation; + Scale = component.Scale; + Anchor = component.Anchor; + Origin = component.Origin; + + if (component is Container container) + { + foreach (var child in container.OfType().OfType()) + Children.Add(child.CreateSkinnableInfo()); + } + } + + /// + /// Construct an instance of the drawable with all attributes applied. + /// + /// The new instance. + public Drawable CreateInstance() + { + Drawable d = (Drawable)Activator.CreateInstance(Type); + d.ApplySkinnableInfo(this); + return d; + } + } +} diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 3dffab8102..ffe03815f5 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,9 +16,9 @@ using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Screens.Play @@ -24,31 +26,27 @@ namespace osu.Game.Screens.Play [Cached] public class HUDOverlay : Container, IKeyBindingHandler { - public const float FADE_DURATION = 400; + public const float FADE_DURATION = 300; - public const Easing FADE_EASING = Easing.Out; + public const Easing FADE_EASING = Easing.OutQuint; /// /// The total height of all the top of screen scoring elements. /// public float TopScoringElementsHeight { get; private set; } + /// + /// The total height of all the bottom of screen scoring elements. + /// + public float BottomScoringElementsHeight { get; private set; } + public readonly KeyCounterDisplay KeyCounter; - public readonly SkinnableComboCounter ComboCounter; - public readonly SkinnableScoreCounter ScoreCounter; - public readonly SkinnableAccuracyCounter AccuracyCounter; - public readonly SkinnableHealthDisplay HealthDisplay; - public readonly SongProgress Progress; public readonly ModDisplay ModDisplay; - public readonly HitErrorDisplay HitErrorDisplay; public readonly HoldForMenuButton HoldToQuit; public readonly PlayerSettingsOverlay PlayerSettingsOverlay; - public readonly FailingLayer FailingLayer; public Bindable ShowHealthbar = new Bindable(true); - private readonly ScoreProcessor scoreProcessor; - private readonly HealthProcessor healthProcessor; private readonly DrawableRuleset drawableRuleset; private readonly IReadOnlyList mods; @@ -65,8 +63,6 @@ namespace osu.Game.Screens.Play private static bool hasShownNotificationOnce; - public Action RequestSeek; - private readonly FillFlowContainer bottomRightElements; private readonly FillFlowContainer topRightElements; @@ -74,12 +70,12 @@ namespace osu.Game.Screens.Play private bool holdingForHUD; - private IEnumerable hideTargets => new Drawable[] { visibilityContainer, KeyCounter }; + private readonly SkinnableTargetContainer mainComponents; - public HUDOverlay(ScoreProcessor scoreProcessor, HealthProcessor healthProcessor, DrawableRuleset drawableRuleset, IReadOnlyList mods) + private IEnumerable hideTargets => new Drawable[] { visibilityContainer, KeyCounter, topRightElements }; + + public HUDOverlay(DrawableRuleset drawableRuleset, IReadOnlyList mods) { - this.scoreProcessor = scoreProcessor; - this.healthProcessor = healthProcessor; this.drawableRuleset = drawableRuleset; this.mods = mods; @@ -87,40 +83,13 @@ namespace osu.Game.Screens.Play Children = new Drawable[] { - FailingLayer = CreateFailingLayer(), + CreateFailingLayer(), visibilityContainer = new Container { RelativeSizeAxes = Axes.Both, - Child = new GridContainer + Child = mainComponents = new SkinnableTargetContainer(SkinnableTarget.MainHUDComponents) { RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - HealthDisplay = CreateHealthDisplay(), - AccuracyCounter = CreateAccuracyCounter(), - ScoreCounter = CreateScoreCounter(), - ComboCounter = CreateComboCounter(), - HitErrorDisplay = CreateHitErrorDisplayOverlay(), - } - }, - }, - new Drawable[] - { - Progress = CreateProgress(), - } - }, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize) - } }, }, topRightElements = new FillFlowContainer @@ -159,19 +128,9 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader(true)] private void load(OsuConfigManager config, NotificationOverlay notificationOverlay) { - if (scoreProcessor != null) - BindScoreProcessor(scoreProcessor); - - if (healthProcessor != null) - BindHealthProcessor(healthProcessor); - if (drawableRuleset != null) { BindDrawableRuleset(drawableRuleset); - - Progress.Objects = drawableRuleset.Objects; - Progress.RequestSeek = time => RequestSeek(time); - Progress.ReferenceClock = drawableRuleset.FrameStableClock; } ModDisplay.Current.Value = mods; @@ -198,7 +157,6 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); - ShowHealthbar.BindValueChanged(healthBar => HealthDisplay.FadeTo(healthBar.NewValue ? 1 : 0, FADE_DURATION, FADE_EASING), true); ShowHud.BindValueChanged(visible => hideTargets.ForEach(d => d.FadeTo(visible.NewValue ? 1 : 0, FADE_DURATION, FADE_EASING))); IsBreakTime.BindValueChanged(_ => updateVisibility()); @@ -211,12 +169,41 @@ namespace osu.Game.Screens.Play { base.Update(); - // HACK: for now align with the accuracy counter. - // this is done for the sake of hacky legacy skins which extend the health bar to take up the full screen area. - // it only works with the default skin due to padding offsetting it *just enough* to coexist. - topRightElements.Y = TopScoringElementsHeight = ToLocalSpace(AccuracyCounter.Drawable.ScreenSpaceDrawQuad.BottomRight).Y; + Vector2? lowestTopScreenSpace = null; + Vector2? highestBottomScreenSpace = null; - bottomRightElements.Y = -Progress.Height; + // LINQ cast can be removed when IDrawable interface includes Anchor / RelativeSizeAxes. + foreach (var element in mainComponents.Components.Cast()) + { + // for now align top-right components with the bottom-edge of the lowest top-anchored hud element. + if (element.Anchor.HasFlagFast(Anchor.TopRight) || (element.Anchor.HasFlagFast(Anchor.y0) && element.RelativeSizeAxes == Axes.X)) + { + // health bars are excluded for the sake of hacky legacy skins which extend the health bar to take up the full screen area. + if (element is LegacyHealthDisplay) + continue; + + var bottomRight = element.ScreenSpaceDrawQuad.BottomRight; + if (lowestTopScreenSpace == null || bottomRight.Y > lowestTopScreenSpace.Value.Y) + lowestTopScreenSpace = bottomRight; + } + // and align bottom-right components with the top-edge of the highest bottom-anchored hud element. + else if (element.Anchor.HasFlagFast(Anchor.BottomRight) || (element.Anchor.HasFlagFast(Anchor.y2) && element.RelativeSizeAxes == Axes.X)) + { + var topLeft = element.ScreenSpaceDrawQuad.TopLeft; + if (highestBottomScreenSpace == null || topLeft.Y < highestBottomScreenSpace.Value.Y) + highestBottomScreenSpace = topLeft; + } + } + + if (lowestTopScreenSpace.HasValue) + topRightElements.Y = TopScoringElementsHeight = MathHelper.Clamp(ToLocalSpace(lowestTopScreenSpace.Value).Y, 0, DrawHeight - topRightElements.DrawHeight); + else + topRightElements.Y = 0; + + if (highestBottomScreenSpace.HasValue) + bottomRightElements.Y = BottomScoringElementsHeight = -MathHelper.Clamp(DrawHeight - ToLocalSpace(highestBottomScreenSpace.Value).Y, 0, DrawHeight - bottomRightElements.DrawHeight); + else + bottomRightElements.Y = 0; } private void updateVisibility() @@ -272,74 +259,33 @@ namespace osu.Game.Screens.Play (drawableRuleset as ICanAttachKeyCounter)?.Attach(KeyCounter); replayLoaded.BindTo(drawableRuleset.HasReplayLoaded); - - Progress.BindDrawableRuleset(drawableRuleset); } - protected virtual SkinnableAccuracyCounter CreateAccuracyCounter() => new SkinnableAccuracyCounter(); - - protected virtual SkinnableScoreCounter CreateScoreCounter() => new SkinnableScoreCounter(); - - protected virtual SkinnableComboCounter CreateComboCounter() => new SkinnableComboCounter(); - - protected virtual SkinnableHealthDisplay CreateHealthDisplay() => new SkinnableHealthDisplay(); - - protected virtual FailingLayer CreateFailingLayer() => new FailingLayer + protected FailingLayer CreateFailingLayer() => new FailingLayer { ShowHealth = { BindTarget = ShowHealthbar } }; - protected virtual KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay + protected KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, }; - protected virtual SongProgress CreateProgress() => new SongProgress - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - }; - - protected virtual HoldForMenuButton CreateHoldForMenuButton() => new HoldForMenuButton + protected HoldForMenuButton CreateHoldForMenuButton() => new HoldForMenuButton { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, }; - protected virtual ModDisplay CreateModsContainer() => new ModDisplay + protected ModDisplay CreateModsContainer() => new ModDisplay { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, }; - protected virtual HitErrorDisplay CreateHitErrorDisplayOverlay() => new HitErrorDisplay(scoreProcessor, drawableRuleset?.FirstAvailableHitWindows); - - protected virtual PlayerSettingsOverlay CreatePlayerSettingsOverlay() => new PlayerSettingsOverlay(); - - protected virtual void BindScoreProcessor(ScoreProcessor processor) - { - ScoreCounter?.Current.BindTo(processor.TotalScore); - AccuracyCounter?.Current.BindTo(processor.Accuracy); - ComboCounter?.Current.BindTo(processor.Combo); - - if (HealthDisplay is IHealthDisplay shd) - { - processor.NewJudgement += judgement => - { - if (judgement.IsHit && judgement.Type != HitResult.IgnoreHit) - shd.Flash(judgement); - }; - } - } - - protected virtual void BindHealthProcessor(HealthProcessor processor) - { - HealthDisplay?.BindHealthProcessor(processor); - FailingLayer?.BindHealthProcessor(processor); - } + protected PlayerSettingsOverlay CreatePlayerSettingsOverlay() => new PlayerSettingsOverlay(); public bool OnPressed(GlobalAction action) { diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs new file mode 100644 index 0000000000..fcbc6fae15 --- /dev/null +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -0,0 +1,233 @@ +// 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; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; + +namespace osu.Game.Screens.Play +{ + /// + /// A which uses a as a source. + /// + /// This is the most complete which takes into account all user and platform offsets, + /// and provides implementations for user actions such as skipping or adjusting playback rates that may occur during gameplay. + /// + /// + /// + /// This is intended to be used as a single controller for gameplay, or as a reference source for other s. + /// + public class MasterGameplayClockContainer : GameplayClockContainer + { + /// + /// Duration before gameplay start time required before skip button displays. + /// + public const double MINIMUM_SKIP_TIME = 1000; + + protected Track Track => (Track)SourceClock; + + public readonly BindableNumber UserPlaybackRate = new BindableDouble(1) + { + Default = 1, + MinValue = 0.5, + MaxValue = 2, + Precision = 0.1, + }; + + private double totalOffset => userOffsetClock.Offset + platformOffsetClock.Offset; + + private readonly BindableDouble pauseFreqAdjust = new BindableDouble(1); + + private readonly WorkingBeatmap beatmap; + private readonly double gameplayStartTime; + private readonly bool startAtGameplayStart; + private readonly double firstHitObjectTime; + + private FramedOffsetClock userOffsetClock; + private FramedOffsetClock platformOffsetClock; + private MasterGameplayClock masterGameplayClock; + private Bindable userAudioOffset; + private double startOffset; + + public MasterGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime, bool startAtGameplayStart = false) + : base(beatmap.Track) + { + this.beatmap = beatmap; + this.gameplayStartTime = gameplayStartTime; + this.startAtGameplayStart = startAtGameplayStart; + + firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); + userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true); + + // sane default provided by ruleset. + startOffset = gameplayStartTime; + + if (!startAtGameplayStart) + { + startOffset = Math.Min(0, startOffset); + + // if a storyboard is present, it may dictate the appropriate start time by having events in negative time space. + // this is commonly used to display an intro before the audio track start. + double? firstStoryboardEvent = beatmap.Storyboard.EarliestEventTime; + if (firstStoryboardEvent != null) + startOffset = Math.Min(startOffset, firstStoryboardEvent.Value); + + // some beatmaps specify a current lead-in time which should be used instead of the ruleset-provided value when available. + // this is not available as an option in the live editor but can still be applied via .osu editing. + if (beatmap.BeatmapInfo.AudioLeadIn > 0) + startOffset = Math.Min(startOffset, firstHitObjectTime - beatmap.BeatmapInfo.AudioLeadIn); + } + + Seek(startOffset); + } + + protected override void OnIsPausedChanged(ValueChangedEvent isPaused) + { + // The source is stopped by a frequency fade first. + if (isPaused.NewValue) + this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => AdjustableSource.Stop()); + else + this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); + } + + public override void Start() + { + addSourceClockAdjustments(); + base.Start(); + } + + /// + /// Seek to a specific time in gameplay. + /// + /// + /// Adjusts for any offsets which have been applied (so the seek may not be the expected point in time on the underlying audio track). + /// + /// The destination time to seek to. + public override void Seek(double time) + { + // remove the offset component here because most of the time we want the seek to be aligned to gameplay, not the audio track. + // we may want to consider reversing the application of offsets in the future as it may feel more correct. + base.Seek(time - totalOffset); + } + + /// + /// Skip forward to the next valid skip point. + /// + public void Skip() + { + if (GameplayClock.CurrentTime > gameplayStartTime - MINIMUM_SKIP_TIME) + return; + + double skipTarget = gameplayStartTime - MINIMUM_SKIP_TIME; + + if (GameplayClock.CurrentTime < 0 && skipTarget > 6000) + // double skip exception for storyboards with very long intros + skipTarget = 0; + + Seek(skipTarget); + } + + public override void Reset() + { + base.Reset(); + Seek(startOffset); + } + + protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) + { + // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. + // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. + platformOffsetClock = new HardwareCorrectionOffsetClock(source) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; + + // the final usable gameplay clock with user-set offsets applied. + userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock); + + return masterGameplayClock = new MasterGameplayClock(userOffsetClock); + } + + /// + /// Changes the backing clock to avoid using the originally provided track. + /// + public void StopUsingBeatmapClock() + { + removeSourceClockAdjustments(); + ChangeSource(new TrackVirtual(beatmap.Track.Length)); + addSourceClockAdjustments(); + } + + private bool speedAdjustmentsApplied; + + private void addSourceClockAdjustments() + { + if (speedAdjustmentsApplied) + return; + + Track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); + Track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + + masterGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust); + masterGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate); + + speedAdjustmentsApplied = true; + } + + private void removeSourceClockAdjustments() + { + if (!speedAdjustmentsApplied) + return; + + Track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); + Track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + + masterGameplayClock.MutableNonGameplayAdjustments.Remove(pauseFreqAdjust); + masterGameplayClock.MutableNonGameplayAdjustments.Remove(UserPlaybackRate); + + speedAdjustmentsApplied = false; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + removeSourceClockAdjustments(); + } + + private class HardwareCorrectionOffsetClock : FramedOffsetClock + { + // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. + // base implementation already adds offset at 1.0 rate, so we only add the difference from that here. + public override double CurrentTime => base.CurrentTime + Offset * (Rate - 1); + + public HardwareCorrectionOffsetClock(IClock source, bool processSource = true) + : base(source, processSource) + { + } + } + + private class MasterGameplayClock : GameplayClock + { + public readonly List> MutableNonGameplayAdjustments = new List>(); + + public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments; + + public MasterGameplayClock(FramedOffsetClock underlyingClock) + : base(underlyingClock) + { + } + } + } +} diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 1e130b7f88..a9f3edf049 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -22,6 +22,7 @@ using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.IO.Archives; using osu.Game.Online.API; +using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Replays; using osu.Game.Rulesets; @@ -39,7 +40,7 @@ namespace osu.Game.Screens.Play { [Cached] [Cached(typeof(ISamplePlaybackDisabler))] - public class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler + public abstract class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler { /// /// The delay upon completion of the beatmap before displaying the results screen. @@ -93,6 +94,9 @@ namespace osu.Game.Screens.Play [Resolved] private MusicController musicController { get; set; } + [Resolved] + private SpectatorClient spectatorClient { get; set; } + private Sample sampleRestart; public BreakOverlay BreakOverlay; @@ -104,7 +108,8 @@ namespace osu.Game.Screens.Play private BreakTracker breakTracker; - private SkipOverlay skipOverlay; + private SkipOverlay skipIntroOverlay; + private SkipOverlay skipOutroOverlay; protected ScoreProcessor ScoreProcessor { get; private set; } @@ -135,7 +140,7 @@ namespace osu.Game.Screens.Play /// /// Create a new player instance. /// - public Player(PlayerConfiguration configuration = null) + protected Player(PlayerConfiguration configuration = null) { Configuration = configuration ?? new PlayerConfiguration(); } @@ -153,6 +158,9 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); + if (!LoadedBeatmapSuccessfully) + return; + // replays should never be recorded or played back when autoplay is enabled if (!Mods.Value.Any(m => m is ModAutoplay)) PrepareReplay(); @@ -197,14 +205,19 @@ namespace osu.Game.Screens.Play LocalUserPlaying.BindTo(osuGame.LocalUserPlaying); DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value); + dependencies.CacheAs(DrawableRuleset); ScoreProcessor = ruleset.CreateScoreProcessor(); ScoreProcessor.ApplyBeatmap(playableBeatmap); ScoreProcessor.Mods.BindTo(Mods); + dependencies.CacheAs(ScoreProcessor); + HealthProcessor = ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); HealthProcessor.ApplyBeatmap(playableBeatmap); + dependencies.CacheAs(HealthProcessor); + if (!ScoreProcessor.Mode.Disabled) config.BindWith(OsuSetting.ScoreDisplayMode, ScoreProcessor.Mode); @@ -244,7 +257,6 @@ namespace osu.Game.Screens.Play HUDOverlay.ShowHud.Value = false; HUDOverlay.ShowHud.Disabled = true; BreakOverlay.Hide(); - skipOverlay.Hide(); } DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting => @@ -281,8 +293,14 @@ namespace osu.Game.Screens.Play ScoreProcessor.RevertResult(r); }; + DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded => + { + if (storyboardEnded.NewValue && completionProgressDelegate == null) + updateCompletionState(); + }; + // Bind the judgement processors to ourselves - ScoreProcessor.HasCompleted.ValueChanged += updateCompletionState; + ScoreProcessor.HasCompleted.BindValueChanged(_ => updateCompletionState()); HealthProcessor.Failed += onFail; foreach (var mod in Mods.Value.OfType()) @@ -295,7 +313,7 @@ namespace osu.Game.Screens.Play IsBreakTime.BindValueChanged(onBreakTimeChanged, true); } - protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new GameplayClockContainer(beatmap, gameplayStart); + protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); private Drawable createUnderlayComponents() => DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard) { RelativeSizeAxes = Axes.Both }; @@ -335,31 +353,30 @@ namespace osu.Game.Screens.Play // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), - HUDOverlay = new HUDOverlay(ScoreProcessor, HealthProcessor, DrawableRuleset, Mods.Value) + HUDOverlay = new HUDOverlay(DrawableRuleset, Mods.Value) { HoldToQuit = { Action = () => PerformExit(true), IsPaused = { BindTarget = GameplayClockContainer.IsPaused } }, - PlayerSettingsOverlay = { PlaybackSettings = { UserPlaybackRate = { BindTarget = GameplayClockContainer.UserPlaybackRate } } }, KeyCounter = { AlwaysVisible = { BindTarget = DrawableRuleset.HasReplayLoaded }, IsCounting = false }, - RequestSeek = time => - { - GameplayClockContainer.Seek(time); - GameplayClockContainer.Start(); - }, Anchor = Anchor.Centre, Origin = Anchor.Centre }, - skipOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) + skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) { RequestSkip = performUserRequestedSkip }, + skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0) + { + RequestSkip = () => updateCompletionState(true), + Alpha = 0 + }, FailOverlay = new FailOverlay { OnRetry = Restart, @@ -386,8 +403,14 @@ namespace osu.Game.Screens.Play } }; - if (!Configuration.AllowSkippingIntro) - skipOverlay.Expire(); + if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays) + { + skipIntroOverlay.Expire(); + skipOutroOverlay.Expire(); + } + + if (GameplayClockContainer is MasterGameplayClockContainer master) + HUDOverlay.PlayerSettingsOverlay.PlaybackSettings.UserPlaybackRate.BindTarget = master.UserPlaybackRate; if (Configuration.AllowRestart) { @@ -427,11 +450,18 @@ namespace osu.Game.Screens.Play private void updatePauseOnFocusLostState() { - if (!PauseOnFocusLost || breakTracker.IsBreakTime.Value) + if (!PauseOnFocusLost || !pausingSupportedByCurrentState || breakTracker.IsBreakTime.Value) return; if (gameActive.Value == false) - Pause(); + { + bool paused = Pause(); + + // if the initial pause could not be satisfied, the pause cooldown may be active. + // reschedule the pause attempt until it can be achieved. + if (!paused) + Scheduler.AddOnce(updatePauseOnFocusLostState); + } } private IBeatmap loadPlayableBeatmap() @@ -492,7 +522,10 @@ namespace osu.Game.Screens.Play if (!this.IsCurrentScreen()) { ValidForResume = false; - this.MakeCurrent(); + + // in the potential case that this instance has already been exited, this is required to avoid a crash. + if (this.GetChildScreen() != null) + this.MakeCurrent(); return; } @@ -516,6 +549,12 @@ namespace osu.Game.Screens.Play Pause(); return; } + + // if the score is ready for display but results screen has not been pushed yet (e.g. storyboard is still playing beyond gameplay), then transition to results screen instead of exiting. + if (prepareScoreForDisplayTask != null && completionProgressDelegate == null) + { + updateCompletionState(true); + } } this.Exit(); @@ -526,12 +565,19 @@ namespace osu.Game.Screens.Play // user requested skip // disable sample playback to stop currently playing samples and perform skip samplePlaybackDisabled.Value = true; - GameplayClockContainer.Skip(); + + (GameplayClockContainer as MasterGameplayClockContainer)?.Skip(); // return samplePlaybackDisabled.Value to what is defined by the beatmap's current state updateSampleDisabledState(); } + /// + /// Seek to a specific time in gameplay. + /// + /// The destination time to seek to. + public void Seek(double time) => GameplayClockContainer.Seek(time); + /// /// Restart gameplay via a parent . /// This can be called from a child screen in order to trigger the restart process. @@ -552,19 +598,25 @@ namespace osu.Game.Screens.Play } private ScheduledDelegate completionProgressDelegate; - private Task scoreSubmissionTask; + private Task prepareScoreForDisplayTask; - private void updateCompletionState(ValueChangedEvent completionState) + /// + /// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime. + /// + /// If in a state where a storyboard outro is to be played, offers the choice of skipping beyond it. + /// Thrown if this method is called more than once without changing state. + private void updateCompletionState(bool skipStoryboardOutro = false) { // screen may be in the exiting transition phase. if (!this.IsCurrentScreen()) return; - if (!completionState.NewValue) + if (!ScoreProcessor.HasCompleted.Value) { completionProgressDelegate?.Cancel(); completionProgressDelegate = null; ValidForResume = true; + skipOutroOverlay.Hide(); return; } @@ -579,22 +631,22 @@ namespace osu.Game.Screens.Play if (!Configuration.ShowResults) return; - scoreSubmissionTask ??= Task.Run(async () => + prepareScoreForDisplayTask ??= Task.Run(async () => { var score = CreateScore(); try { - await SubmitScore(score); + await PrepareScoreForResultsAsync(score).ConfigureAwait(false); } catch (Exception ex) { - Logger.Error(ex, "Score submission failed!"); + Logger.Error(ex, "Score preparation failed!"); } try { - await ImportScore(score); + await ImportScore(score).ConfigureAwait(false); } catch (Exception ex) { @@ -604,13 +656,27 @@ namespace osu.Game.Screens.Play return score.ScoreInfo; }); + if (skipStoryboardOutro) + { + scheduleCompletion(); + return; + } + + bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value; + + if (storyboardHasOutro) + { + skipOutroOverlay.Show(); + return; + } + using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY)) scheduleCompletion(); } private void scheduleCompletion() => completionProgressDelegate = Schedule(() => { - if (!scoreSubmissionTask.IsCompleted) + if (!prepareScoreForDisplayTask.IsCompleted) { scheduleCompletion(); return; @@ -618,7 +684,7 @@ namespace osu.Game.Screens.Play // screen may be in the exiting transition phase. if (this.IsCurrentScreen()) - this.Push(CreateResults(scoreSubmissionTask.Result)); + this.Push(CreateResults(prepareScoreForDisplayTask.Result)); }); protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value; @@ -674,6 +740,9 @@ namespace osu.Game.Screens.Play private double? lastPauseActionTime; + protected bool PauseCooldownActive => + lastPauseActionTime.HasValue && GameplayClockContainer.GameplayClock.CurrentTime < lastPauseActionTime + pause_cooldown; + /// /// A set of conditionals which defines whether the current game state and configuration allows for /// pausing to be attempted via . If false, the game should generally exit if a user pause @@ -684,11 +753,9 @@ namespace osu.Game.Screens.Play LoadedBeatmapSuccessfully && Configuration.AllowPause && ValidForResume // replays cannot be paused and exit immediately && !DrawableRuleset.HasReplayLoaded.Value + // cannot pause if we are already in a fail state && !HasFailed; - private bool pauseCooldownActive => - lastPauseActionTime.HasValue && GameplayClockContainer.GameplayClock.CurrentTime < lastPauseActionTime + pause_cooldown; - private bool canResume => // cannot resume from a non-paused state GameplayClockContainer.IsPaused.Value @@ -697,12 +764,12 @@ namespace osu.Game.Screens.Play // already resuming && !IsResuming; - public void Pause() + public bool Pause() { - if (!pausingSupportedByCurrentState) return; + if (!pausingSupportedByCurrentState) return false; - if (!IsResuming && pauseCooldownActive) - return; + if (!IsResuming && PauseCooldownActive) + return false; if (IsResuming) { @@ -713,6 +780,7 @@ namespace osu.Game.Screens.Play GameplayClockContainer.Stop(); PauseOverlay.Show(); lastPauseActionTime = GameplayClockContainer.GameplayClock.CurrentTime; + return true; } public void Resume() @@ -755,7 +823,7 @@ namespace osu.Game.Screens.Play ApplyToBackground(b => { - b.EnableUserDim.Value = true; + b.IgnoreUserSettings.Value = false; b.BlurAmount.Value = 0; // bind component bindables. @@ -799,7 +867,7 @@ namespace osu.Game.Screens.Play if (GameplayClockContainer.GameplayClock.IsRunning) throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running"); - GameplayClockContainer.Restart(); + GameplayClockContainer.Reset(); } public override void OnSuspending(IScreen next) @@ -821,9 +889,14 @@ namespace osu.Game.Screens.Play return true; } + // EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous. + // To resolve test failures, forcefully end playing synchronously when this screen exits. + // Todo: Replace this with a more permanent solution once osu-framework has a synchronous cleanup method. + spectatorClient.EndPlaying(); + // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track. // as we are no longer the current screen, we cannot guarantee the track is still usable. - GameplayClockContainer?.StopUsingBeatmapClock(); + (GameplayClockContainer as MasterGameplayClockContainer)?.StopUsingBeatmapClock(); musicController.ResetTrackAdjustments(); @@ -868,11 +941,11 @@ namespace osu.Game.Screens.Play /// /// The to import. /// The imported score. - protected virtual Task ImportScore(Score score) + protected virtual async Task ImportScore(Score score) { // Replays are already populated and present in the game's database, so should not be re-imported. if (DrawableRuleset.ReplayScore != null) - return Task.CompletedTask; + return; LegacyByteArrayReader replayReader; @@ -882,15 +955,26 @@ namespace osu.Game.Screens.Play replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); } - return scoreManager.Import(score.ScoreInfo, replayReader); + // For the time being, online ID responses are not really useful for anything. + // In addition, the IDs provided via new (lazer) endpoints are based on a different autoincrement from legacy (stable) scores. + // + // Until we better define the server-side logic behind this, let's not store the online ID to avoid potential unique constraint + // conflicts across various systems (ie. solo and multiplayer). + long? onlineScoreId = score.ScoreInfo.OnlineScoreID; + score.ScoreInfo.OnlineScoreID = null; + + await scoreManager.Import(score.ScoreInfo, replayReader).ConfigureAwait(false); + + // ... And restore the online ID for other processes to handle correctly (e.g. de-duplication for the results screen). + score.ScoreInfo.OnlineScoreID = onlineScoreId; } /// - /// Submits the player's . + /// Prepare the for display at results. /// - /// The to submit. - /// The submitted score. - protected virtual Task SubmitScore(Score score) => Task.CompletedTask; + /// The to prepare. + /// A task that prepares the provided score. On completion, the score is assumed to be ready for display. + protected virtual Task PrepareScoreForResultsAsync(Score score) => Task.CompletedTask; /// /// Creates the for a . @@ -904,7 +988,7 @@ namespace osu.Game.Screens.Play float fadeOutDuration = instant ? 0 : 250; this.FadeOut(fadeOutDuration); - ApplyToBackground(b => b.EnableUserDim.Value = false); + ApplyToBackground(b => b.IgnoreUserSettings.Value = true); storyboardReplacesBackground.Value = false; } diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs index cd30ead638..18ee73374f 100644 --- a/osu.Game/Screens/Play/PlayerConfiguration.cs +++ b/osu.Game/Screens/Play/PlayerConfiguration.cs @@ -21,8 +21,8 @@ namespace osu.Game.Screens.Play public bool AllowRestart { get; set; } = true; /// - /// Whether the player should be allowed to skip the intro, advancing to the start of gameplay. + /// Whether the player should be allowed to skip intros/outros, advancing to the start of gameplay or the end of a storyboard. /// - public bool AllowSkippingIntro { get; set; } = true; + public bool AllowSkipping { get; set; } = true; } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 5b4bd11216..ce580e2b53 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics; -using System.Linq; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -25,6 +24,7 @@ using osu.Game.Overlays.Notifications; using osu.Game.Screens.Menu; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Users; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -113,6 +113,9 @@ namespace osu.Game.Screens.Play [Resolved] private AudioManager audioManager { get; set; } + [Resolved(CanBeNull = true)] + private BatteryInfo batteryInfo { get; set; } + public PlayerLoader(Func createPlayer) { this.createPlayer = createPlayer; @@ -122,6 +125,7 @@ namespace osu.Game.Screens.Play private void load(SessionStatics sessionStatics) { muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce); + batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce); InternalChild = (content = new LogoTrackingContainer { @@ -197,6 +201,7 @@ namespace osu.Game.Screens.Play Scheduler.Add(new ScheduledDelegate(pushWhenLoaded, Clock.CurrentTime + 1800, 0)); showMuteWarningIfNeeded(); + showBatteryWarningIfNeeded(); } public override void OnResuming(IScreen last) @@ -230,7 +235,7 @@ namespace osu.Game.Screens.Play content.ScaleTo(0.7f, 150, Easing.InQuint); this.FadeOut(150); - ApplyToBackground(b => b.EnableUserDim.Value = false); + ApplyToBackground(b => b.IgnoreUserSettings.Value = true); BackgroundBrightnessReduction = false; Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); @@ -278,7 +283,7 @@ namespace osu.Game.Screens.Play // Preview user-defined background dim and blur when hovered on the visual settings panel. ApplyToBackground(b => { - b.EnableUserDim.Value = true; + b.IgnoreUserSettings.Value = false; b.BlurAmount.Value = 0; }); @@ -289,7 +294,7 @@ namespace osu.Game.Screens.Play ApplyToBackground(b => { // Returns background dim and blur to the values specified by PlayerLoader. - b.EnableUserDim.Value = false; + b.IgnoreUserSettings.Value = true; b.BlurAmount.Value = BACKGROUND_BLUR; }); @@ -310,10 +315,8 @@ namespace osu.Game.Screens.Play if (!this.IsCurrentScreen()) return; - var restartCount = player?.RestartCount + 1 ?? 0; - player = createPlayer(); - player.RestartCount = restartCount; + player.RestartCount = restartCount++; player.RestartRequested = restartRequested; LoadTask = LoadComponentAsync(player, _ => MetadataInfo.Loading = false); @@ -429,6 +432,8 @@ namespace osu.Game.Screens.Play private Bindable muteWarningShownOnce; + private int restartCount; + private void showMuteWarningIfNeeded() { if (!muteWarningShownOnce.Value) @@ -471,5 +476,48 @@ namespace osu.Game.Screens.Play } #endregion + + #region Low battery warning + + private Bindable batteryWarningShownOnce; + + private void showBatteryWarningIfNeeded() + { + if (batteryInfo == null) return; + + if (!batteryWarningShownOnce.Value) + { + if (!batteryInfo.IsCharging && batteryInfo.ChargeLevel <= 0.25) + { + notificationOverlay?.Post(new BatteryWarningNotification()); + batteryWarningShownOnce.Value = true; + } + } + } + + private class BatteryWarningNotification : SimpleNotification + { + public override bool IsImportant => true; + + public BatteryWarningNotification() + { + Text = "Your battery level is low! Charge your device to prevent interruptions during gameplay."; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, NotificationOverlay notificationOverlay) + { + Icon = FontAwesome.Solid.BatteryQuarter; + IconBackgound.Colour = colours.RedDark; + + Activated = delegate + { + notificationOverlay.Hide(); + return true; + }; + } + } + + #endregion } } diff --git a/osu.Game/Screens/Play/RoomSubmittingPlayer.cs b/osu.Game/Screens/Play/RoomSubmittingPlayer.cs new file mode 100644 index 0000000000..7ba12f5db6 --- /dev/null +++ b/osu.Game/Screens/Play/RoomSubmittingPlayer.cs @@ -0,0 +1,38 @@ +// 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.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Play +{ + /// + /// A player instance which submits to a room backing. This is generally used by playlists and multiplayer. + /// + public abstract class RoomSubmittingPlayer : SubmittingPlayer + { + [Resolved(typeof(Room), nameof(Room.RoomID))] + protected Bindable RoomId { get; private set; } + + protected readonly PlaylistItem PlaylistItem; + + protected RoomSubmittingPlayer(PlaylistItem playlistItem, PlayerConfiguration configuration = null) + : base(configuration) + { + PlaylistItem = playlistItem; + } + + protected override APIRequest CreateTokenRequest() + { + if (!(RoomId.Value is long roomId)) + return null; + + return new CreateRoomScoreRequest(roomId, PlaylistItem.ID, Game.VersionHash); + } + + protected override APIRequest CreateSubmissionRequest(Score score, long token) => new SubmitRoomScoreRequest(token, RoomId.Value ?? 0, PlaylistItem.ID, score.ScoreInfo); + } +} diff --git a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs index 8585a5c309..30ca15c311 100644 --- a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs +++ b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,6 +18,8 @@ namespace osu.Game.Screens.Play private readonly GameplayClockContainer gameplayClockContainer; private Bindable isPaused; + private readonly Bindable disableSuspensionBindable = new Bindable(); + [Resolved] private GameHost host { get; set; } @@ -31,12 +32,14 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); - // This is the only usage game-wide of suspension changes. - // Assert to ensure we don't accidentally forget this in the future. - Debug.Assert(host.AllowScreenSuspension.Value); - isPaused = gameplayClockContainer.IsPaused.GetBoundCopy(); - isPaused.BindValueChanged(paused => host.AllowScreenSuspension.Value = paused.NewValue, true); + isPaused.BindValueChanged(paused => + { + if (paused.NewValue) + host.AllowScreenSuspension.RemoveSource(disableSuspensionBindable); + else + host.AllowScreenSuspension.AddSource(disableSuspensionBindable); + }, true); } protected override void Dispose(bool isDisposing) @@ -44,9 +47,7 @@ namespace osu.Game.Screens.Play base.Dispose(isDisposing); isPaused?.UnbindAll(); - - if (host != null) - host.AllowScreenSuspension.Value = true; + host?.AllowScreenSuspension.RemoveSource(disableSuspensionBindable); } } } diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index 3f214e49d9..ed49fc40b2 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -8,19 +8,19 @@ 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.Sprites; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; using osu.Game.Screens.Ranking; using osuTK; using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics.Containers; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Framework.Utils; -using osu.Game.Input.Bindings; namespace osu.Game.Screens.Play { @@ -90,7 +90,19 @@ namespace osu.Game.Screens.Play private const double fade_time = 300; - private double fadeOutBeginTime => startTime - GameplayClockContainer.MINIMUM_SKIP_TIME; + private double fadeOutBeginTime => startTime - MasterGameplayClockContainer.MINIMUM_SKIP_TIME; + + public override void Hide() + { + base.Hide(); + fadeContainer.Hide(); + } + + public override void Show() + { + base.Show(); + fadeContainer.Show(); + } protected override void LoadComplete() { @@ -147,7 +159,7 @@ namespace osu.Game.Screens.Play { } - private class FadeContainer : Container, IStateful + public class FadeContainer : Container, IStateful { public event Action StateChanged; @@ -170,7 +182,7 @@ namespace osu.Game.Screens.Play switch (state) { case Visibility.Visible: - // we may be triggered to become visible mnultiple times but we only want to transform once. + // we may be triggered to become visible multiple times but we only want to transform once. if (stateChanged) this.FadeIn(500, Easing.OutExpo); diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs new file mode 100644 index 0000000000..d0ef4131dc --- /dev/null +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Online.Solo; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Play +{ + public class SoloPlayer : SubmittingPlayer + { + protected override APIRequest CreateTokenRequest() + { + if (!(Beatmap.Value.BeatmapInfo.OnlineBeatmapID is int beatmapId)) + return null; + + if (!(Ruleset.Value.ID is int rulesetId)) + return null; + + return new CreateSoloScoreRequest(beatmapId, rulesetId, Game.VersionHash); + } + + protected override bool HandleTokenRetrievalFailure(Exception exception) => false; + + protected override APIRequest CreateSubmissionRequest(Score score, long token) + { + Debug.Assert(Beatmap.Value.BeatmapInfo.OnlineBeatmapID != null); + + int beatmapId = Beatmap.Value.BeatmapInfo.OnlineBeatmapID.Value; + + return new SubmitSoloScoreRequest(beatmapId, token, score.ScoreInfo); + } + } +} diff --git a/osu.Game/Screens/Play/Spectator.cs b/osu.Game/Screens/Play/SoloSpectator.cs similarity index 52% rename from osu.Game/Screens/Play/Spectator.cs rename to osu.Game/Screens/Play/SoloSpectator.cs index 28311f5113..820d776e63 100644 --- a/osu.Game/Screens/Play/Spectator.cs +++ b/osu.Game/Screens/Play/SoloSpectator.cs @@ -1,18 +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; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using JetBrains.Annotations; 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.Screens; +using osu.Framework.Threading; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -24,73 +21,49 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.Spectator; using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Overlays.Settings; -using osu.Game.Replays; using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Replays; -using osu.Game.Rulesets.Replays.Types; -using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Screens.Spectate; using osu.Game.Users; using osuTK; namespace osu.Game.Screens.Play { [Cached(typeof(IPreviewTrackOwner))] - public class Spectator : OsuScreen, IPreviewTrackOwner + public class SoloSpectator : SpectatorScreen, IPreviewTrackOwner { + [NotNull] private readonly User targetUser; - [Resolved] - private Bindable beatmap { get; set; } - - [Resolved] - private Bindable ruleset { get; set; } - - private Ruleset rulesetInstance; - - [Resolved] - private Bindable> mods { get; set; } - [Resolved] private IAPIProvider api { get; set; } [Resolved] - private SpectatorStreamingClient spectatorStreaming { get; set; } - - [Resolved] - private BeatmapManager beatmaps { get; set; } + private PreviewTrackManager previewTrackManager { get; set; } [Resolved] private RulesetStore rulesets { get; set; } [Resolved] - private PreviewTrackManager previewTrackManager { get; set; } - - private Score score; - - private readonly object scoreLock = new object(); + private BeatmapManager beatmaps { get; set; } private Container beatmapPanelContainer; - - private SpectatorState state; - - private IBindable> managerUpdated; - private TriangleButton watchButton; - private SettingsCheckbox automaticDownload; - private BeatmapSetInfo onlineBeatmap; /// - /// Becomes true if a new state is waiting to be loaded (while this screen was not active). + /// The player's immediate online gameplay state. + /// This doesn't always reflect the gameplay state being watched. /// - private bool newStatePending; + private GameplayState immediateGameplayState; - public Spectator([NotNull] User targetUser) + private GetBeatmapSetRequest onlineBeatmapRequest; + + public SoloSpectator([NotNull] User targetUser) + : base(targetUser.Id) { - this.targetUser = targetUser ?? throw new ArgumentNullException(nameof(targetUser)); + this.targetUser = targetUser; } [BackgroundDependencyLoader] @@ -173,7 +146,7 @@ namespace osu.Game.Screens.Play Width = 250, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Action = attemptStart, + Action = () => scheduleStart(immediateGameplayState), Enabled = { Value = false } } } @@ -185,169 +158,76 @@ namespace osu.Game.Screens.Play protected override void LoadComplete() { base.LoadComplete(); - - spectatorStreaming.OnUserBeganPlaying += userBeganPlaying; - spectatorStreaming.OnUserFinishedPlaying += userFinishedPlaying; - spectatorStreaming.OnNewFrames += userSentFrames; - - spectatorStreaming.WatchUser(targetUser.Id); - - managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); - managerUpdated.BindValueChanged(beatmapUpdated); - automaticDownload.Current.BindValueChanged(_ => checkForAutomaticDownload()); } - private void beatmapUpdated(ValueChangedEvent> beatmap) + protected override void OnUserStateChanged(int userId, SpectatorState spectatorState) { - if (beatmap.NewValue.TryGetTarget(out var beatmapSet) && beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == state.BeatmapID)) - Schedule(attemptStart); + clearDisplay(); + showBeatmapPanel(spectatorState); } - private void userSentFrames(int userId, FrameDataBundle data) + protected override void StartGameplay(int userId, GameplayState gameplayState) { - // this is not scheduled as it handles propagation of frames even when in a child screen (at which point we are not alive). - // probably not the safest way to handle this. + immediateGameplayState = gameplayState; + watchButton.Enabled.Value = true; - if (userId != targetUser.Id) - return; - - lock (scoreLock) - { - // this should never happen as the server sends the user's state on watching, - // but is here as a safety measure. - if (score == null) - return; - - // rulesetInstance should be guaranteed to be in sync with the score via scoreLock. - Debug.Assert(rulesetInstance != null && rulesetInstance.RulesetInfo.Equals(score.ScoreInfo.Ruleset)); - - foreach (var frame in data.Frames) - { - IConvertibleReplayFrame convertibleFrame = rulesetInstance.CreateConvertibleReplayFrame(); - convertibleFrame.FromLegacy(frame, beatmap.Value.Beatmap); - - var convertedFrame = (ReplayFrame)convertibleFrame; - convertedFrame.Time = frame.Time; - - score.Replay.Frames.Add(convertedFrame); - } - } + scheduleStart(gameplayState); } - private void userBeganPlaying(int userId, SpectatorState state) + protected override void EndGameplay(int userId) { - if (userId != targetUser.Id) - return; + scheduledStart?.Cancel(); + immediateGameplayState = null; + watchButton.Enabled.Value = false; - this.state = state; - - if (this.IsCurrentScreen()) - Schedule(attemptStart); - else - newStatePending = true; - } - - public override void OnResuming(IScreen last) - { - base.OnResuming(last); - - if (newStatePending) - { - attemptStart(); - newStatePending = false; - } - } - - private void userFinishedPlaying(int userId, SpectatorState state) - { - if (userId != targetUser.Id) - return; - - lock (scoreLock) - { - if (score != null) - { - score.Replay.HasReceivedAllFrames = true; - score = null; - } - } - - Schedule(clearDisplay); + clearDisplay(); } private void clearDisplay() { watchButton.Enabled.Value = false; + onlineBeatmapRequest?.Cancel(); beatmapPanelContainer.Clear(); previewTrackManager.StopAnyPlaying(this); } - private void attemptStart() + private ScheduledDelegate scheduledStart; + + private void scheduleStart(GameplayState gameplayState) { - clearDisplay(); - showBeatmapPanel(state); - - var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == state.RulesetID)?.CreateInstance(); - - // ruleset not available - if (resolvedRuleset == null) - return; - - if (state.BeatmapID == null) - return; - - var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == state.BeatmapID); - - if (resolvedBeatmap == null) + // This function may be called multiple times in quick succession once the screen becomes current again. + scheduledStart?.Cancel(); + scheduledStart = Schedule(() => { - return; - } + if (this.IsCurrentScreen()) + start(); + else + scheduleStart(gameplayState); + }); - lock (scoreLock) + void start() { - score = new Score - { - ScoreInfo = new ScoreInfo - { - Beatmap = resolvedBeatmap, - User = targetUser, - Mods = state.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(), - Ruleset = resolvedRuleset.RulesetInfo, - }, - Replay = new Replay { HasReceivedAllFrames = false }, - }; + Beatmap.Value = gameplayState.Beatmap; + Ruleset.Value = gameplayState.Ruleset.RulesetInfo; - ruleset.Value = resolvedRuleset.RulesetInfo; - rulesetInstance = resolvedRuleset; - - beatmap.Value = beatmaps.GetWorkingBeatmap(resolvedBeatmap); - watchButton.Enabled.Value = true; - - this.Push(new SpectatorPlayerLoader(score)); + this.Push(new SpectatorPlayerLoader(gameplayState.Score)); } } private void showBeatmapPanel(SpectatorState state) { - if (state?.BeatmapID == null) - { - onlineBeatmap = null; - return; - } + Debug.Assert(state.BeatmapID != null); - var req = new GetBeatmapSetRequest(state.BeatmapID.Value, BeatmapSetLookupType.BeatmapId); - req.Success += res => Schedule(() => + onlineBeatmapRequest = new GetBeatmapSetRequest(state.BeatmapID.Value, BeatmapSetLookupType.BeatmapId); + onlineBeatmapRequest.Success += res => Schedule(() => { - if (state != this.state) - return; - onlineBeatmap = res.ToBeatmapSet(rulesets); beatmapPanelContainer.Child = new GridBeatmapPanel(onlineBeatmap); checkForAutomaticDownload(); }); - api.Queue(req); + api.Queue(onlineBeatmapRequest); } private void checkForAutomaticDownload() @@ -369,21 +249,5 @@ namespace osu.Game.Screens.Play previewTrackManager.StopAnyPlaying(this); return base.OnExiting(next); } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (spectatorStreaming != null) - { - spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; - spectatorStreaming.OnUserFinishedPlaying -= userFinishedPlaying; - spectatorStreaming.OnNewFrames -= userSentFrames; - - spectatorStreaming.StopWatchingUser(targetUser.Id); - } - - managerUpdated?.UnbindAll(); - } } } diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index acf4640aa4..cab44c7473 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -14,15 +14,20 @@ using osu.Framework.Timing; using osu.Game.Configuration; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; +using osu.Game.Skinning; namespace osu.Game.Screens.Play { - public class SongProgress : OverlayContainer + public class SongProgress : OverlayContainer, ISkinnableDrawable { - private const int info_height = 20; - private const int bottom_bar_height = 5; + public const float MAX_HEIGHT = info_height + bottom_bar_height + graph_height + handle_height; + + private const float info_height = 20; + private const float bottom_bar_height = 5; private const float graph_height = SquareGraph.Column.WIDTH * 6; - private static readonly Vector2 handle_size = new Vector2(10, 18); + private const float handle_height = 18; + + private static readonly Vector2 handle_size = new Vector2(10, handle_height); private const float transition_duration = 200; @@ -39,14 +44,16 @@ namespace osu.Game.Screens.Play public readonly Bindable ShowGraph = new Bindable(); - //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). - private double lastHitTime => objects.Last().GetEndTime() + 1; - public override bool HandleNonPositionalInput => AllowSeeking.Value; public override bool HandlePositionalInput => AllowSeeking.Value; + protected override bool BlockScrollInput => false; + private double firstHitTime => objects.First().StartTime; + //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). + private double lastHitTime => objects.Last().GetEndTime() + 1; + private IEnumerable objects; public IEnumerable Objects @@ -63,13 +70,19 @@ namespace osu.Game.Screens.Play } } - public IClock ReferenceClock; + [Resolved(canBeNull: true)] + private Player player { get; set; } - private IClock gameplayClock; + [Resolved(canBeNull: true)] + private GameplayClock gameplayClock { get; set; } + + private IClock referenceClock; public SongProgress() { - Masking = true; + RelativeSizeAxes = Axes.X; + Anchor = Anchor.BottomRight; + Origin = Anchor.BottomRight; Children = new Drawable[] { @@ -92,18 +105,23 @@ namespace osu.Game.Screens.Play { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - OnSeek = time => RequestSeek?.Invoke(time), + OnSeek = time => player?.Seek(time), }, }; } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, GameplayClock clock, OsuConfigManager config) + private void load(OsuColour colours, OsuConfigManager config, DrawableRuleset drawableRuleset) { base.LoadComplete(); - if (clock != null) - gameplayClock = clock; + if (drawableRuleset != null) + { + AllowSeeking.BindTo(drawableRuleset.HasReplayLoaded); + + referenceClock = drawableRuleset.FrameStableClock; + Objects = drawableRuleset.Objects; + } config.BindWith(OsuSetting.ShowProgressGraph, ShowGraph); @@ -118,11 +136,6 @@ namespace osu.Game.Screens.Play ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); } - public void BindDrawableRuleset(DrawableRuleset drawableRuleset) - { - AllowSeeking.BindTo(drawableRuleset.HasReplayLoaded); - } - protected override void PopIn() { this.FadeIn(500, Easing.OutQuint); @@ -141,7 +154,7 @@ namespace osu.Game.Screens.Play return; double gameplayTime = gameplayClock?.CurrentTime ?? Time.Current; - double frameStableTime = ReferenceClock?.CurrentTime ?? gameplayTime; + double frameStableTime = referenceClock?.CurrentTime ?? gameplayTime; double progress = Math.Min(1, (frameStableTime - firstHitTime) / (lastHitTime - firstHitTime)); diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index fdf996150f..a8125dfded 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -31,12 +31,12 @@ namespace osu.Game.Screens.Play } [Resolved] - private SpectatorStreamingClient spectatorStreaming { get; set; } + private SpectatorClient spectatorClient { get; set; } [BackgroundDependencyLoader] private void load() { - spectatorStreaming.OnUserBeganPlaying += userBeganPlaying; + spectatorClient.OnUserBeganPlaying += userBeganPlaying; AddInternal(new OsuSpriteText { @@ -61,12 +61,12 @@ namespace osu.Game.Screens.Play if (firstFrameTime == null || firstFrameTime <= gameplayStart + 5000) return base.CreateGameplayClockContainer(beatmap, gameplayStart); - return new GameplayClockContainer(beatmap, firstFrameTime.Value, true); + return new MasterGameplayClockContainer(beatmap, firstFrameTime.Value, true); } public override bool OnExiting(IScreen next) { - spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; + spectatorClient.OnUserBeganPlaying -= userBeganPlaying; return base.OnExiting(next); } @@ -84,8 +84,8 @@ namespace osu.Game.Screens.Play { base.Dispose(isDisposing); - if (spectatorStreaming != null) - spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; + if (spectatorClient != null) + spectatorClient.OnUserBeganPlaying -= userBeganPlaying; } } } diff --git a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs index 580af81166..bdd23962dc 100644 --- a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs +++ b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs @@ -12,7 +12,12 @@ namespace osu.Game.Screens.Play public readonly ScoreInfo Score; public SpectatorPlayerLoader(Score score) - : base(() => new SpectatorPlayer(score)) + : this(score, () => new SpectatorPlayer(score)) + { + } + + public SpectatorPlayerLoader(Score score, Func createPlayer) + : base(createPlayer) { if (score.Replay == null) throw new ArgumentException($"{nameof(score)} must have a non-null {nameof(score.Replay)}.", nameof(score)); diff --git a/osu.Game/Screens/Play/SpectatorResultsScreen.cs b/osu.Game/Screens/Play/SpectatorResultsScreen.cs index dabdf0a139..fd7af3af85 100644 --- a/osu.Game/Screens/Play/SpectatorResultsScreen.cs +++ b/osu.Game/Screens/Play/SpectatorResultsScreen.cs @@ -17,12 +17,12 @@ namespace osu.Game.Screens.Play } [Resolved] - private SpectatorStreamingClient spectatorStreaming { get; set; } + private SpectatorClient spectatorClient { get; set; } [BackgroundDependencyLoader] private void load() { - spectatorStreaming.OnUserBeganPlaying += userBeganPlaying; + spectatorClient.OnUserBeganPlaying += userBeganPlaying; } private void userBeganPlaying(int userId, SpectatorState state) @@ -40,8 +40,8 @@ namespace osu.Game.Screens.Play { base.Dispose(isDisposing); - if (spectatorStreaming != null) - spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; + if (spectatorClient != null) + spectatorClient.OnUserBeganPlaying -= userBeganPlaying; } } } diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs new file mode 100644 index 0000000000..23b9037244 --- /dev/null +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -0,0 +1,148 @@ +// 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 System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Play +{ + /// + /// A player instance which supports submitting scores to an online store. + /// + public abstract class SubmittingPlayer : Player + { + /// + /// The token to be used for the current submission. This is fetched via a request created by . + /// + private long? token; + + [Resolved] + private IAPIProvider api { get; set; } + + protected SubmittingPlayer(PlayerConfiguration configuration = null) + : base(configuration) + { + } + + protected override void LoadAsyncComplete() + { + base.LoadAsyncComplete(); + handleTokenRetrieval(); + } + + private bool handleTokenRetrieval() + { + // Token request construction should happen post-load to allow derived classes to potentially prepare DI backings that are used to create the request. + var tcs = new TaskCompletionSource(); + + if (Mods.Value.Any(m => m is ModAutoplay)) + { + handleTokenFailure(new InvalidOperationException("Autoplay loaded.")); + return false; + } + + if (!api.IsLoggedIn) + { + handleTokenFailure(new InvalidOperationException("API is not online.")); + return false; + } + + var req = CreateTokenRequest(); + + if (req == null) + { + handleTokenFailure(new InvalidOperationException("Request could not be constructed.")); + return false; + } + + req.Success += r => + { + token = r.ID; + tcs.SetResult(true); + }; + req.Failure += handleTokenFailure; + + api.Queue(req); + + tcs.Task.Wait(); + return true; + + void handleTokenFailure(Exception exception) + { + if (HandleTokenRetrievalFailure(exception)) + { + if (string.IsNullOrEmpty(exception.Message)) + Logger.Error(exception, "Failed to retrieve a score submission token."); + else + Logger.Log($"You are not able to submit a score: {exception.Message}", level: LogLevel.Important); + + Schedule(() => + { + ValidForResume = false; + this.Exit(); + }); + } + + tcs.SetResult(false); + } + } + + /// + /// Called when a token could not be retrieved for submission. + /// + /// The error causing the failure. + /// Whether gameplay should be immediately exited as a result. Returning false allows the gameplay session to continue. Defaults to true. + protected virtual bool HandleTokenRetrievalFailure(Exception exception) => true; + + protected override async Task PrepareScoreForResultsAsync(Score score) + { + await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); + + // token may be null if the request failed but gameplay was still allowed (see HandleTokenRetrievalFailure). + if (token == null) + return; + + var tcs = new TaskCompletionSource(); + var request = CreateSubmissionRequest(score, token.Value); + + request.Success += s => + { + score.ScoreInfo.OnlineScoreID = s.ID; + tcs.SetResult(true); + }; + + request.Failure += e => + { + Logger.Error(e, "Failed to submit score"); + tcs.SetResult(false); + }; + + api.Queue(request); + await tcs.Task.ConfigureAwait(false); + } + + /// + /// Construct a request to be used for retrieval of the score token. + /// Can return null, at which point will be fired. + /// + [CanBeNull] + protected abstract APIRequest CreateTokenRequest(); + + /// + /// Construct a request to submit the score. + /// Will only be invoked if the request constructed via was successful. + /// + /// The score to be submitted. + /// The submission token. + protected abstract APIRequest CreateSubmissionRequest(Score score, long token); + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index bca3a07fa6..c70b4dd35b 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -10,11 +10,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; -using osu.Game.Audio; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; -using osu.Game.Skinning; using osuTK; namespace osu.Game.Screens.Ranking.Expanded.Accuracy @@ -76,19 +74,14 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private readonly ScoreInfo score; - private readonly bool withFlair; - private SmoothCircularProgress accuracyCircle; private SmoothCircularProgress innerMask; private Container badges; private RankText rankText; - private SkinnableSound applauseSound; - - public AccuracyCircle(ScoreInfo score, bool withFlair) + public AccuracyCircle(ScoreInfo score) { this.score = score; - this.withFlair = withFlair; } [BackgroundDependencyLoader] @@ -211,13 +204,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy }, rankText = new RankText(score.Rank) }; - - if (withFlair) - { - AddInternal(applauseSound = score.Rank >= ScoreRank.A - ? new SkinnableSound(new SampleInfo("Results/rankpass", "applause")) - : new SkinnableSound(new SampleInfo("Results/rankfail"))); - } } private ScoreRank getRank(ScoreRank rank) @@ -256,7 +242,6 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy using (BeginDelayedSequence(TEXT_APPEAR_DELAY, true)) { - this.Delay(-1440).Schedule(() => applauseSound?.Play()); rankText.Appear(); } } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index ff6203bc25..4895240314 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -101,7 +101,7 @@ namespace osu.Game.Screens.Ranking.Expanded { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, Truncate = true, @@ -110,7 +110,7 @@ namespace osu.Game.Screens.Ranking.Expanded { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, Truncate = true, @@ -122,7 +122,7 @@ namespace osu.Game.Screens.Ranking.Expanded Margin = new MarginPadding { Top = 40 }, RelativeSizeAxes = Axes.X, Height = 230, - Child = new AccuracyCircle(score, withFlair) + Child = new AccuracyCircle(score) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs b/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs index f7e50fdc8a..7aba699216 100644 --- a/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs +++ b/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs @@ -3,12 +3,14 @@ using System.Globalization; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -20,9 +22,21 @@ namespace osu.Game.Screens.Ranking.Expanded /// /// A pill that displays the star rating of a . /// - public class StarRatingDisplay : CompositeDrawable + public class StarRatingDisplay : CompositeDrawable, IHasCurrentValue { - private readonly StarDifficulty difficulty; + private Box background; + private OsuTextFlowContainer textFlow; + + [Resolved] + private OsuColour colours { get; set; } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } /// /// Creates a new using an already computed . @@ -30,7 +44,7 @@ namespace osu.Game.Screens.Ranking.Expanded /// The already computed to display the star difficulty of. public StarRatingDisplay(StarDifficulty starDifficulty) { - difficulty = starDifficulty; + Current.Value = starDifficulty; } [BackgroundDependencyLoader] @@ -38,15 +52,6 @@ namespace osu.Game.Screens.Ranking.Expanded { AutoSizeAxes = Axes.Both; - var starRatingParts = difficulty.Stars.ToString("0.00", CultureInfo.InvariantCulture).Split('.'); - string wholePart = starRatingParts[0]; - string fractionPart = starRatingParts[1]; - string separator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; - - ColourInfo backgroundColour = difficulty.DifficultyRating == DifficultyRating.ExpertPlus - ? ColourInfo.GradientVertical(Color4Extensions.FromHex("#C1C1C1"), Color4Extensions.FromHex("#595959")) - : (ColourInfo)colours.ForDifficultyRating(difficulty.DifficultyRating); - InternalChildren = new Drawable[] { new CircularContainer @@ -55,10 +60,9 @@ namespace osu.Game.Screens.Ranking.Expanded Masking = true, Children = new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = backgroundColour }, } }, @@ -78,32 +82,54 @@ namespace osu.Game.Screens.Ranking.Expanded Icon = FontAwesome.Solid.Star, Colour = Color4.Black }, - new OsuTextFlowContainer(s => s.Font = OsuFont.Numeric.With(weight: FontWeight.Black)) + textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.Numeric.With(weight: FontWeight.Black)) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, TextAnchor = Anchor.BottomLeft, - }.With(t => - { - t.AddText($"{wholePart}", s => - { - s.Colour = Color4.Black; - s.Font = s.Font.With(size: 14); - s.UseFullGlyphHeight = false; - }); - - t.AddText($"{separator}{fractionPart}", s => - { - s.Colour = Color4.Black; - s.Font = s.Font.With(size: 7); - s.UseFullGlyphHeight = false; - }); - }) + } } } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + var starRatingParts = Current.Value.Stars.ToString("0.00", CultureInfo.InvariantCulture).Split('.'); + string wholePart = starRatingParts[0]; + string fractionPart = starRatingParts[1]; + string separator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; + + var rating = Current.Value.DifficultyRating; + + background.Colour = rating == DifficultyRating.ExpertPlus + ? ColourInfo.GradientVertical(Color4Extensions.FromHex("#C1C1C1"), Color4Extensions.FromHex("#595959")) + : (ColourInfo)colours.ForDifficultyRating(rating); + + textFlow.Clear(); + + textFlow.AddText($"{wholePart}", s => + { + s.Colour = Color4.Black; + s.Font = s.Font.With(size: 14); + s.UseFullGlyphHeight = false; + }); + + textFlow.AddText($"{separator}{fractionPart}", s => + { + s.Colour = Color4.Black; + s.Font = s.Font.With(size: 7); + s.UseFullGlyphHeight = false; + }); + } } } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index c1f5d92d17..a0ea27b640 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Screens; +using osu.Game.Audio; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -19,13 +20,20 @@ using osu.Game.Online.API; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking.Expanded.Accuracy; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Screens.Ranking { public abstract class ResultsScreen : ScreenWithBeatmapBackground, IKeyBindingHandler { + /// + /// Delay before the default applause sound should be played, in order to match the grade display timing in . + /// + public const double APPLAUSE_DELAY = AccuracyCircle.ACCURACY_TRANSFORM_DELAY + AccuracyCircle.TEXT_APPEAR_DELAY + ScorePanel.RESIZE_DURATION + ScorePanel.TOP_LAYER_EXPAND_DELAY - 1440; + protected const float BACKGROUND_BLUR = 20; private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y; @@ -56,6 +64,8 @@ namespace osu.Game.Screens.Ranking private readonly bool allowRetry; private readonly bool allowWatchingReplay; + private SkinnableSound applauseSound; + protected ResultsScreen(ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true) { Score = score; @@ -146,6 +156,13 @@ namespace osu.Game.Screens.Ranking bool shouldFlair = player != null && !Score.Mods.Any(m => m is ModAutoplay); ScorePanelList.AddScore(Score, shouldFlair); + + if (shouldFlair) + { + AddInternal(applauseSound = Score.Rank >= ScoreRank.A + ? new SkinnableSound(new SampleInfo("Results/rankpass", "applause")) + : new SkinnableSound(new SampleInfo("Results/rankfail"))); + } } if (allowWatchingReplay) @@ -183,6 +200,9 @@ namespace osu.Game.Screens.Ranking api.Queue(req); statisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); + + using (BeginDelayedSequence(APPLAUSE_DELAY)) + Schedule(() => applauseSound?.Play()); } protected override void Update() diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index df710e4eb8..f66a998db6 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -54,12 +54,12 @@ namespace osu.Game.Screens.Ranking /// /// Duration for the panel to resize into its expanded/contracted size. /// - private const double resize_duration = 200; + public const double RESIZE_DURATION = 200; /// - /// Delay after before the top layer is expanded. + /// Delay after before the top layer is expanded. /// - private const double top_layer_expand_delay = 100; + public const double TOP_LAYER_EXPAND_DELAY = 100; /// /// Duration for the top layer expansion. @@ -208,8 +208,8 @@ namespace osu.Game.Screens.Ranking case PanelState.Expanded: Size = new Vector2(EXPANDED_WIDTH, expanded_height); - topLayerBackground.FadeColour(expanded_top_layer_colour, resize_duration, Easing.OutQuint); - middleLayerBackground.FadeColour(expanded_middle_layer_colour, resize_duration, Easing.OutQuint); + topLayerBackground.FadeColour(expanded_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); + middleLayerBackground.FadeColour(expanded_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User).With(d => d.Alpha = 0)); middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair).With(d => d.Alpha = 0)); @@ -221,20 +221,20 @@ namespace osu.Game.Screens.Ranking case PanelState.Contracted: Size = new Vector2(CONTRACTED_WIDTH, contracted_height); - topLayerBackground.FadeColour(contracted_top_layer_colour, resize_duration, Easing.OutQuint); - middleLayerBackground.FadeColour(contracted_middle_layer_colour, resize_duration, Easing.OutQuint); + topLayerBackground.FadeColour(contracted_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); + middleLayerBackground.FadeColour(contracted_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); topLayerContentContainer.Add(topLayerContent = new ContractedPanelTopContent(Score).With(d => d.Alpha = 0)); middleLayerContentContainer.Add(middleLayerContent = new ContractedPanelMiddleContent(Score).With(d => d.Alpha = 0)); break; } - content.ResizeTo(Size, resize_duration, Easing.OutQuint); + content.ResizeTo(Size, RESIZE_DURATION, Easing.OutQuint); bool topLayerExpanded = topLayerContainer.Y < 0; // If the top layer was already expanded, then we don't need to wait for the resize and can instead transform immediately. This looks better when changing the panel state. - using (BeginDelayedSequence(topLayerExpanded ? 0 : resize_duration + top_layer_expand_delay, true)) + using (BeginDelayedSequence(topLayerExpanded ? 0 : RESIZE_DURATION + TOP_LAYER_EXPAND_DELAY, true)) { topLayerContainer.FadeIn(); diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index 77b3d8fc3b..441c9e048a 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -226,7 +226,6 @@ namespace osu.Game.Screens.Ranking /// /// Enumerates all s contained in this . /// - /// public IEnumerable GetScorePanels() => flow.Select(t => t.Panel); /// diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 76b549da1a..9bc696948f 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -15,6 +15,8 @@ namespace osu.Game.Screens.Ranking { public class SoloResultsScreen : ResultsScreen { + private GetScoresRequest getScoreRequest; + [Resolved] private RulesetStore rulesets { get; set; } @@ -28,9 +30,16 @@ namespace osu.Game.Screens.Ranking if (Score.Beatmap.OnlineBeatmapID == null || Score.Beatmap.Status <= BeatmapSetOnlineStatus.Pending) return null; - var req = new GetScoresRequest(Score.Beatmap, Score.Ruleset); - req.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineScoreID != Score.OnlineScoreID).Select(s => s.CreateScoreInfo(rulesets))); - return req; + getScoreRequest = new GetScoresRequest(Score.Beatmap, Score.Ruleset); + getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineScoreID != Score.OnlineScoreID).Select(s => s.CreateScoreInfo(rulesets))); + return getScoreRequest; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + getScoreRequest?.Cancel(); } } } diff --git a/osu.Game/Screens/ScreenWhiteBox.cs b/osu.Game/Screens/ScreenWhiteBox.cs index 3d8fd5dad7..cf0c183766 100644 --- a/osu.Game/Screens/ScreenWhiteBox.cs +++ b/osu.Game/Screens/ScreenWhiteBox.cs @@ -87,9 +87,9 @@ namespace osu.Game.Screens private static Color4 getColourFor(object type) { int hash = type.GetHashCode(); - byte r = (byte)Math.Clamp(((hash & 0xFF0000) >> 16) * 0.8f, 20, 255); - byte g = (byte)Math.Clamp(((hash & 0x00FF00) >> 8) * 0.8f, 20, 255); - byte b = (byte)Math.Clamp((hash & 0x0000FF) * 0.8f, 20, 255); + byte r = (byte)Math.Clamp(((hash & 0xFF0000) >> 16) * 2, 128, 255); + byte g = (byte)Math.Clamp(((hash & 0x00FF00) >> 8) * 2, 128, 255); + byte b = (byte)Math.Clamp((hash & 0x0000FF) * 2, 128, 255); return new Color4(r, g, b, 255); } @@ -109,10 +109,10 @@ namespace osu.Game.Screens private readonly Container boxContainer; - public UnderConstructionMessage(string name) + public UnderConstructionMessage(string name, string description = "is not yet ready for use!") { - RelativeSizeAxes = Axes.Both; - Size = new Vector2(0.3f); + AutoSizeAxes = Axes.Both; + Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -124,7 +124,7 @@ namespace osu.Game.Screens { CornerRadius = 20, Masking = true, - RelativeSizeAxes = Axes.Both, + AutoSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, Children = new Drawable[] @@ -133,15 +133,15 @@ namespace osu.Game.Screens { RelativeSizeAxes = Axes.Both, - Colour = colour, - Alpha = 0.2f, - Blending = BlendingParameters.Additive, + Colour = colour.Darken(0.8f), + Alpha = 0.8f, }, TextContainer = new FillFlowContainer { AutoSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, + Padding = new MarginPadding(20), Direction = FillDirection.Vertical, Children = new Drawable[] { @@ -157,14 +157,14 @@ namespace osu.Game.Screens Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = name, - Colour = colour.Lighten(0.8f), + Colour = colour, Font = OsuFont.GetFont(size: 36), }, new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = "is not yet ready for use!", + Text = description, Font = OsuFont.GetFont(size: 20), }, new OsuSpriteText diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 8a1c291fca..26da4279f0 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Sprites; using System.Linq; using osu.Game.Online.API; -using osu.Framework.Threading; using osu.Framework.Graphics.Shapes; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Screens.Select.Details; @@ -19,6 +18,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; +using osu.Game.Online; namespace osu.Game.Screens.Select { @@ -27,11 +27,8 @@ namespace osu.Game.Screens.Select private const float spacing = 10; private const float transition_duration = 250; - private readonly FillFlowContainer top, statsFlow; private readonly AdvancedStats advanced; - private readonly DetailBox ratingsContainer; private readonly UserRatings ratings; - private readonly OsuScrollContainer metadataScroll; private readonly MetadataSection description, source, tags; private readonly Container failRetryContainer; private readonly FailRetryGraph failRetryGraph; @@ -40,8 +37,6 @@ namespace osu.Game.Screens.Select [Resolved] private IAPIProvider api { get; set; } - private ScheduledDelegate pendingBeatmapSwitch; - [Resolved] private RulesetStore rulesets { get; set; } @@ -56,8 +51,7 @@ namespace osu.Game.Screens.Select beatmap = value; - pendingBeatmapSwitch?.Cancel(); - pendingBeatmapSwitch = Schedule(updateStatistics); + Scheduler.AddOnce(updateStatistics); } } @@ -76,99 +70,105 @@ namespace osu.Game.Screens.Select Padding = new MarginPadding { Horizontal = spacing }, Children = new Drawable[] { - top = new FillFlowContainer + new GridContainer { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Children = new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - statsFlow = new FillFlowContainer + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Width = 0.5f, - Spacing = new Vector2(spacing), - Padding = new MarginPadding { Right = spacing / 2 }, - Children = new[] - { - new DetailBox - { - Child = advanced = new AdvancedStats - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = spacing, Top = spacing * 2, Bottom = spacing }, - }, - }, - ratingsContainer = new DetailBox - { - Child = ratings = new UserRatings - { - RelativeSizeAxes = Axes.X, - Height = 134, - Padding = new MarginPadding { Horizontal = spacing, Top = spacing }, - }, - }, - }, - }, - metadataScroll = new OsuScrollContainer - { - RelativeSizeAxes = Axes.X, - Width = 0.5f, - ScrollbarVisible = false, - Padding = new MarginPadding { Left = spacing / 2 }, - Child = new FillFlowContainer + new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - LayoutDuration = transition_duration, - LayoutEasing = Easing.OutQuad, - Spacing = new Vector2(spacing * 2), - Margin = new MarginPadding { Top = spacing * 2 }, - Children = new[] + Direction = FillDirection.Horizontal, + Children = new Drawable[] { - description = new MetadataSection("Description"), - source = new MetadataSection("Source"), - tags = new MetadataSection("Tags"), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Width = 0.5f, + Spacing = new Vector2(spacing), + 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, + Height = 134, + Padding = new MarginPadding { Horizontal = spacing, Top = spacing }, + Child = ratings = new UserRatings + { + RelativeSizeAxes = Axes.Both, + }, + }), + }, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + ScrollbarVisible = false, + Padding = new MarginPadding { Left = spacing / 2 }, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + LayoutDuration = transition_duration, + LayoutEasing = Easing.OutQuad, + Spacing = new Vector2(spacing * 2), + Margin = new MarginPadding { Top = spacing * 2 }, + Children = new[] + { + description = new MetadataSection("Description"), + source = new MetadataSection("Source"), + tags = new MetadataSection("Tags"), + }, + }, + }, }, }, }, - }, - }, - failRetryContainer = new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Children = new Drawable[] - { - new OsuSpriteText + new Drawable[] { - Text = "Points of Failure", - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), - }, - failRetryGraph = new FailRetryGraph - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 14 + spacing / 2 }, - }, - }, + failRetryContainer = new OnlineViewContainer("Sign in to view more details") + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Points of Failure", + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), + }, + failRetryGraph = new FailRetryGraph + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 14 + spacing / 2 }, + }, + }, + }, + } + } }, }, }, - loading = new LoadingLayer(true), + loading = new LoadingLayer(true) }; } - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - metadataScroll.Height = statsFlow.DrawHeight; - failRetryContainer.Height = DrawHeight - Padding.TotalVertical - (top.DrawHeight + spacing / 2); - } - private void updateStatistics() { advanced.Beatmap = Beatmap; @@ -184,7 +184,7 @@ namespace osu.Game.Screens.Select } // for now, let's early abort if an OnlineBeatmapID is not present (should have been populated at import time). - if (Beatmap?.OnlineBeatmapID == null) + if (Beatmap?.OnlineBeatmapID == null || api.State.Value == APIState.Offline) { updateMetrics(); return; @@ -239,12 +239,13 @@ namespace osu.Game.Screens.Select if (hasRatings) { ratings.Metrics = beatmap.BeatmapSet.Metrics; - ratingsContainer.FadeIn(transition_duration); + ratings.FadeIn(transition_duration); } else { + // loading or just has no data server-side. ratings.Metrics = new BeatmapSetMetrics { Ratings = new int[10] }; - ratingsContainer.FadeTo(0.25f, transition_duration); + ratings.FadeTo(0.25f, transition_duration); } if (hasRetriesFails) @@ -259,7 +260,6 @@ namespace osu.Game.Screens.Select Fails = new int[100], Retries = new int[100], }; - failRetryContainer.FadeOut(transition_duration); } loading.Hide(); diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 86cb561bc7..e1cf0cef4e 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -5,14 +5,12 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; -using JetBrains.Annotations; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -25,10 +23,12 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; +using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Screens.Ranking.Expanded; +using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Select { @@ -38,14 +38,20 @@ namespace osu.Game.Screens.Select private static readonly Vector2 wedged_container_shear = new Vector2(shear_width / SongSelect.WEDGE_HEIGHT, 0); - private readonly IBindable ruleset = new Bindable(); + [Resolved] + private IBindable ruleset { get; set; } + + [Resolved] + private IBindable> mods { get; set; } [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } - private IBindable beatmapDifficulty; + private IBindable beatmapDifficulty; - protected BufferedWedgeInfo Info; + protected Container DisplayedContent { get; private set; } + + protected WedgeInfoText Info { get; private set; } public BeatmapInfoWedge() { @@ -63,11 +69,10 @@ namespace osu.Game.Screens.Select }; } - [BackgroundDependencyLoader(true)] - private void load([CanBeNull] Bindable parentRuleset) + [BackgroundDependencyLoader] + private void load() { - ruleset.BindTo(parentRuleset); - ruleset.ValueChanged += _ => updateDisplay(); + ruleset.BindValueChanged(_ => updateDisplay()); } protected override void PopIn() @@ -107,9 +112,9 @@ namespace osu.Game.Screens.Select } } - public override bool IsPresent => base.IsPresent || Info == null; // Visibility is updated in the LoadComponentAsync callback + public override bool IsPresent => base.IsPresent || DisplayedContent == null; // Visibility is updated in the LoadComponentAsync callback - private BufferedWedgeInfo loadingInfo; + private Container loadingInfo; private void updateDisplay() { @@ -121,9 +126,9 @@ namespace osu.Game.Screens.Select { State.Value = beatmap == null ? Visibility.Hidden : Visibility.Visible; - Info?.FadeOut(250); - Info?.Expire(); - Info = null; + DisplayedContent?.FadeOut(250); + DisplayedContent?.Expire(); + DisplayedContent = null; } if (beatmap == null) @@ -132,17 +137,23 @@ namespace osu.Game.Screens.Select return; } - LoadComponentAsync(loadingInfo = new BufferedWedgeInfo(beatmap, ruleset.Value, beatmapDifficulty.Value) + LoadComponentAsync(loadingInfo = new Container { + RelativeSizeAxes = Axes.Both, Shear = -Shear, - Depth = Info?.Depth + 1 ?? 0 + Depth = DisplayedContent?.Depth + 1 ?? 0, + Children = new Drawable[] + { + new BeatmapInfoWedgeBackground(beatmap), + Info = new WedgeInfoText(beatmap, ruleset.Value, mods.Value, beatmapDifficulty.Value ?? new StarDifficulty()), + } }, loaded => { // ensure we are the most recent loaded wedge. if (loaded != loadingInfo) return; removeOldInfo(); - Add(Info = loaded); + Add(DisplayedContent = loaded); }); } } @@ -153,27 +164,31 @@ namespace osu.Game.Screens.Select cancellationSource?.Cancel(); } - public class BufferedWedgeInfo : BufferedContainer + public class WedgeInfoText : Container { public OsuSpriteText VersionLabel { get; private set; } public OsuSpriteText TitleLabel { get; private set; } public OsuSpriteText ArtistLabel { get; private set; } public BeatmapSetOnlineStatusPill StatusPill { get; private set; } public FillFlowContainer MapperContainer { get; private set; } - public FillFlowContainer InfoLabelContainer { get; private set; } private ILocalisedBindableString titleBinding; private ILocalisedBindableString artistBinding; + private FillFlowContainer infoLabelContainer; + private Container bpmLabelContainer; private readonly WorkingBeatmap beatmap; private readonly RulesetInfo ruleset; + private readonly IReadOnlyList mods; private readonly StarDifficulty starDifficulty; - public BufferedWedgeInfo(WorkingBeatmap beatmap, RulesetInfo userRuleset, StarDifficulty difficulty) - : base(pixelSnapping: true) + private ModSettingChangeTracker settingChangeTracker; + + public WedgeInfoText(WorkingBeatmap beatmap, RulesetInfo userRuleset, IReadOnlyList mods, StarDifficulty difficulty) { this.beatmap = beatmap; ruleset = userRuleset ?? beatmap.BeatmapInfo.Ruleset; + this.mods = mods; starDifficulty = difficulty; } @@ -183,41 +198,13 @@ namespace osu.Game.Screens.Select var beatmapInfo = beatmap.BeatmapInfo; var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); - CacheDrawnFrameBuffer = true; - RelativeSizeAxes = Axes.Both; - titleBinding = localisation.GetLocalisedString(new LocalisedString((metadata.TitleUnicode, metadata.Title))); - artistBinding = localisation.GetLocalisedString(new LocalisedString((metadata.ArtistUnicode, metadata.Artist))); + titleBinding = localisation.GetLocalisedString(new RomanisableString(metadata.TitleUnicode, metadata.Title)); + artistBinding = localisation.GetLocalisedString(new RomanisableString(metadata.ArtistUnicode, metadata.Artist)); Children = new Drawable[] { - // We will create the white-to-black gradient by modulating transparency and having - // a black backdrop. This results in an sRGB-space gradient and not linear space, - // transitioning from white to black more perceptually uniformly. - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - }, - // We use a container, such that we can set the colour gradient to go across the - // vertices of the masked container instead of the vertices of the (larger) sprite. - new Container - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0.3f)), - Children = new[] - { - // Zoomed-in and cropped beatmap background - new BeatmapBackgroundSprite(beatmap) - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fill, - }, - }, - }, new DifficultyColourBar(starDifficulty) { RelativeSizeAxes = Axes.Y, @@ -300,14 +287,13 @@ namespace osu.Game.Screens.Select Margin = new MarginPadding { Top = 10 }, Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, - Children = getMapper(metadata) + Child = getMapper(metadata), }, - InfoLabelContainer = new FillFlowContainer + infoLabelContainer = new FillFlowContainer { Margin = new MarginPadding { Top = 20 }, Spacing = new Vector2(20, 0), AutoSizeAxes = Axes.Both, - Children = getInfoLabels() } } } @@ -319,6 +305,8 @@ namespace osu.Game.Screens.Select // no difficulty means it can't have a status to show if (beatmapInfo.Version == null) StatusPill.Hide(); + + addInfoLabels(); } private static Drawable createStarRatingDisplay(StarDifficulty difficulty) => difficulty.Stars > 0 @@ -332,86 +320,113 @@ namespace osu.Game.Screens.Select { ArtistLabel.Text = artistBinding.Value; TitleLabel.Text = string.IsNullOrEmpty(source) ? titleBinding.Value : source + " — " + titleBinding.Value; - ForceRedraw(); } - private InfoLabel[] getInfoLabels() + private void addInfoLabels() { - var b = beatmap.Beatmap; + if (beatmap.Beatmap?.HitObjects?.Any() != true) + return; - List labels = new List(); - - if (b?.HitObjects?.Any() == true) + infoLabelContainer.Children = new Drawable[] { - labels.Add(new InfoLabel(new BeatmapStatistic + new InfoLabel(new BeatmapStatistic { Name = "Length", CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), - Content = TimeSpan.FromMilliseconds(b.BeatmapInfo.Length).ToString(@"m\:ss"), - })); - - labels.Add(new InfoLabel(new BeatmapStatistic + Content = TimeSpan.FromMilliseconds(beatmap.BeatmapInfo.Length).ToString(@"m\:ss"), + }), + bpmLabelContainer = new Container { - Name = "BPM", - CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Bpm), - Content = getBPMRange(b), - })); + AutoSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(20, 0), + Children = getRulesetInfoLabels() + } + }; + + settingChangeTracker = new ModSettingChangeTracker(mods); + settingChangeTracker.SettingChanged += _ => refreshBPMLabel(); + + refreshBPMLabel(); + } + + private InfoLabel[] getRulesetInfoLabels() + { + try + { + IBeatmap playableBeatmap; try { - IBeatmap playableBeatmap; - - try - { - // Try to get the beatmap with the user's ruleset - playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty()); - } - catch (BeatmapInvalidForRulesetException) - { - // Can't be converted to the user's ruleset, so use the beatmap's own ruleset - playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset, Array.Empty()); - } - - labels.AddRange(playableBeatmap.GetStatistics().Select(s => new InfoLabel(s))); + // Try to get the beatmap with the user's ruleset + playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty()); } - catch (Exception e) + catch (BeatmapInvalidForRulesetException) { - Logger.Error(e, "Could not load beatmap successfully!"); + // Can't be converted to the user's ruleset, so use the beatmap's own ruleset + playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset, Array.Empty()); } + + return playableBeatmap.GetStatistics().Select(s => new InfoLabel(s)).ToArray(); + } + catch (Exception e) + { + Logger.Error(e, "Could not load beatmap successfully!"); } - return labels.ToArray(); + return Array.Empty(); } - private string getBPMRange(IBeatmap beatmap) + private void refreshBPMLabel() { - double bpmMax = beatmap.ControlPointInfo.BPMMaximum; - double bpmMin = beatmap.ControlPointInfo.BPMMinimum; + var b = beatmap.Beatmap; + if (b == null) + return; - if (Precision.AlmostEquals(bpmMin, bpmMax)) - return $"{bpmMin:0}"; + // this doesn't consider mods which apply variable rates, yet. + double rate = 1; + foreach (var mod in mods.OfType()) + rate = mod.ApplyToRate(0, rate); - return $"{bpmMin:0}-{bpmMax:0} (mostly {60000 / beatmap.GetMostCommonBeatLength():0})"; - } + double bpmMax = b.ControlPointInfo.BPMMaximum * rate; + double bpmMin = b.ControlPointInfo.BPMMinimum * rate; + double mostCommonBPM = 60000 / b.GetMostCommonBeatLength() * rate; - private OsuSpriteText[] getMapper(BeatmapMetadata metadata) - { - if (string.IsNullOrEmpty(metadata.Author?.Username)) - return Array.Empty(); + string labelText = Precision.AlmostEquals(bpmMin, bpmMax) + ? $"{bpmMin:0}" + : $"{bpmMin:0}-{bpmMax:0} (mostly {mostCommonBPM:0})"; - return new[] + bpmLabelContainer.Child = new InfoLabel(new BeatmapStatistic { - new OsuSpriteText - { - Text = "mapped by ", - Font = OsuFont.GetFont(size: 15), - }, - new OsuSpriteText - { - Text = metadata.Author.Username, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 15), - } - }; + Name = "BPM", + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Bpm), + Content = labelText + }); + } + + private Drawable getMapper(BeatmapMetadata metadata) + { + if (metadata.Author == null) + return Empty(); + + return new LinkFlowContainer(s => + { + s.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 15); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.AddText("mapped by "); + d.AddUserLink(metadata.Author); + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + settingChangeTracker?.Dispose(); } public class InfoLabel : Container, IHasTooltip diff --git a/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs b/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs new file mode 100644 index 0000000000..f50fb4dc8a --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapInfoWedgeBackground.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osuTK.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Framework.Graphics.Shapes; + +namespace osu.Game.Screens.Select +{ + internal class BeatmapInfoWedgeBackground : CompositeDrawable + { + private readonly WorkingBeatmap beatmap; + + public BeatmapInfoWedgeBackground(WorkingBeatmap beatmap) + { + this.beatmap = beatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = new BufferedContainer + { + CacheDrawnFrameBuffer = true, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + // We will create the white-to-black gradient by modulating transparency and having + // a black backdrop. This results in an sRGB-space gradient and not linear space, + // transitioning from white to black more perceptually uniformly. + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + // We use a container, such that we can set the colour gradient to go across the + // vertices of the masked container instead of the vertices of the (larger) sprite. + new Container + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0.3f)), + Children = new[] + { + // Zoomed-in and cropped beatmap background + new BeatmapBackgroundSprite(beatmap) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + }, + }, + }, + } + }; + } + } +} diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 1aab50037a..521b90202d 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -73,6 +73,9 @@ namespace osu.Game.Screens.Select.Carousel if (match) match &= criteria.Collection?.Beatmaps.Contains(Beatmap) ?? true; + if (match && criteria.RulesetCriteria != null) + match &= criteria.RulesetCriteria.Matches(Beatmap); + Filtered.Value = !match; } diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs index bf045ed612..00c2c2cb4a 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs @@ -71,6 +71,9 @@ namespace osu.Game.Screens.Select.Carousel case SortMode.Author: return string.Compare(BeatmapSet.Metadata.Author.Username, otherSet.BeatmapSet.Metadata.Author.Username, StringComparison.OrdinalIgnoreCase); + case SortMode.Source: + return string.Compare(BeatmapSet.Metadata.Source, otherSet.BeatmapSet.Metadata.Source, StringComparison.OrdinalIgnoreCase); + case SortMode.DateAdded: return otherSet.BeatmapSet.DateAdded.CompareTo(BeatmapSet.DateAdded); diff --git a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs index 2fbf64de29..40ca3e0764 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs @@ -152,7 +152,7 @@ namespace osu.Game.Screens.Select.Carousel { if (sampleHover == null) return; - sampleHover.Frequency.Value = 0.90 + RNG.NextDouble(0.2); + sampleHover.Frequency.Value = 0.99 + RNG.NextDouble(0.02); sampleHover.Play(); } } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index e66469ff8d..633ef9297e 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -63,7 +63,7 @@ namespace osu.Game.Screens.Select.Carousel [Resolved(CanBeNull = true)] private ManageCollectionsDialog manageCollectionsDialog { get; set; } - private IBindable starDifficultyBindable; + private IBindable starDifficultyBindable; private CancellationTokenSource starDifficultyCancellationSource; public DrawableCarouselBeatmap(CarouselBeatmap panel) @@ -217,7 +217,10 @@ namespace osu.Game.Screens.Select.Carousel { // We've potentially cancelled the computation above so a new bindable is required. starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, (starDifficultyCancellationSource = new CancellationTokenSource()).Token); - starDifficultyBindable.BindValueChanged(d => starCounter.Current = (float)d.NewValue.Stars, true); + starDifficultyBindable.BindValueChanged(d => + { + starCounter.Current = (float)(d.NewValue?.Stars ?? 0); + }, true); } base.ApplyState(); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index d7e901b71e..9773bd5ce9 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -36,7 +36,7 @@ namespace osu.Game.Screens.Select.Carousel [Resolved(CanBeNull = true)] private ManageCollectionsDialog manageCollectionsDialog { get; set; } - public IEnumerable DrawableBeatmaps => beatmapContainer?.Children ?? Enumerable.Empty(); + public IEnumerable DrawableBeatmaps => beatmapContainer?.IsLoaded != true ? Enumerable.Empty() : beatmapContainer.AliveChildren; [CanBeNull] private Container beatmapContainer; @@ -250,7 +250,7 @@ namespace osu.Game.Screens.Select.Carousel else state = TernaryState.False; - return new TernaryStateMenuItem(collection.Name.Value, MenuItemType.Standard, s => + return new TernaryStateToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => { foreach (var b in beatmapSet.Beatmaps) { diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs index 4e8d27f14d..23a02547b2 100644 --- a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs +++ b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs @@ -41,13 +41,13 @@ namespace osu.Game.Screens.Select.Carousel { new OsuSpriteText { - Text = new LocalisedString((beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title)), + Text = new RomanisableString(beatmapSet.Metadata.TitleUnicode, beatmapSet.Metadata.Title), Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 22, italics: true), Shadow = true, }, new OsuSpriteText { - Text = new LocalisedString((beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist)), + Text = new RomanisableString(beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist), Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true), Shadow = true, }, diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index ab4f3f4796..53e30fd9ca 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -15,6 +15,8 @@ using System.Collections.Generic; using osu.Game.Rulesets.Mods; using System.Linq; using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Configuration; @@ -137,8 +139,6 @@ namespace osu.Game.Screens.Select.Details updateStarDifficulty(); } - private IBindable normalStarDifficulty; - private IBindable moddedStarDifficulty; private CancellationTokenSource starDifficultyCancellationSource; private void updateStarDifficulty() @@ -150,13 +150,13 @@ namespace osu.Game.Screens.Select.Details starDifficultyCancellationSource = new CancellationTokenSource(); - normalStarDifficulty = difficultyCache.GetBindableDifficulty(Beatmap, ruleset.Value, null, starDifficultyCancellationSource.Token); - moddedStarDifficulty = difficultyCache.GetBindableDifficulty(Beatmap, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); + var normalStarDifficulty = difficultyCache.GetDifficultyAsync(Beatmap, ruleset.Value, null, starDifficultyCancellationSource.Token); + var moddedStarDifficulty = difficultyCache.GetDifficultyAsync(Beatmap, ruleset.Value, mods.Value, starDifficultyCancellationSource.Token); - normalStarDifficulty.BindValueChanged(_ => updateDisplay()); - moddedStarDifficulty.BindValueChanged(_ => updateDisplay(), true); - - void updateDisplay() => starDifficulty.Value = ((float)normalStarDifficulty.Value.Stars, (float)moddedStarDifficulty.Value.Stars); + Task.WhenAll(normalStarDifficulty, moddedStarDifficulty).ContinueWith(_ => Schedule(() => + { + starDifficulty.Value = ((float)normalStarDifficulty.Result.Stars, (float)moddedStarDifficulty.Result.Stars); + }), starDifficultyCancellationSource.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current); } protected override void Dispose(bool isDisposing) @@ -180,17 +180,17 @@ namespace osu.Game.Screens.Select.Details [Resolved] private OsuColour colours { get; set; } - public string Title + public LocalisableString Title { get => name.Text; set => name.Text = value; } - private (float baseValue, float? adjustedValue) value; + private (float baseValue, float? adjustedValue)? value; public (float baseValue, float? adjustedValue) Value { - get => value; + get => value ?? (0, null); set { if (value == this.value) diff --git a/osu.Game/Screens/Select/Filter/Operator.cs b/osu.Game/Screens/Select/Filter/Operator.cs new file mode 100644 index 0000000000..706daf631f --- /dev/null +++ b/osu.Game/Screens/Select/Filter/Operator.cs @@ -0,0 +1,17 @@ +// 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.Screens.Select.Filter +{ + /// + /// Defines logical operators that can be used in the song select search box keyword filters. + /// + public enum Operator + { + Less, + LessOrEqual, + Equal, + GreaterOrEqual, + Greater + } +} diff --git a/osu.Game/Screens/Select/Filter/SortMode.cs b/osu.Game/Screens/Select/Filter/SortMode.cs index be76fbc3ba..18c5d713e1 100644 --- a/osu.Game/Screens/Select/Filter/SortMode.cs +++ b/osu.Game/Screens/Select/Filter/SortMode.cs @@ -28,7 +28,10 @@ namespace osu.Game.Screens.Select.Filter [Description("Rank Achieved")] RankAchieved, + [Description("Source")] + Source, + [Description("Title")] - Title + Title, } } diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index eafd8a87d1..298b6e49bd 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.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.Bindables; using osu.Framework.Graphics; @@ -36,6 +37,8 @@ namespace osu.Game.Screens.Select public FilterCriteria CreateCriteria() { + Debug.Assert(ruleset.Value.ID != null); + var query = searchTextBox.Text; var criteria = new FilterCriteria @@ -53,6 +56,8 @@ namespace osu.Game.Screens.Select if (!maximumStars.IsDefault) criteria.UserStarDifficulty.Max = maximumStars.Value; + criteria.RulesetCriteria = ruleset.Value.CreateInstance().CreateRulesetFilterCriteria(); + FilterQueryParser.ApplyQueries(criteria, query); return criteria; } diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 7bddb3e51b..208048380a 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -8,6 +8,7 @@ using JetBrains.Annotations; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Rulesets; +using osu.Game.Rulesets.Filter; using osu.Game.Screens.Select.Filter; namespace osu.Game.Screens.Select @@ -69,6 +70,9 @@ namespace osu.Game.Screens.Select [CanBeNull] public BeatmapCollection Collection; + [CanBeNull] + public IRulesetFilterCriteria RulesetCriteria { get; set; } + public struct OptionalRange : IEquatable> where T : struct { diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 4b6b3be45c..ea7f233bea 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -5,13 +5,17 @@ using System; using System.Globalization; using System.Text.RegularExpressions; using osu.Game.Beatmaps; +using osu.Game.Screens.Select.Filter; namespace osu.Game.Screens.Select { - internal static class FilterQueryParser + /// + /// Utility class used for parsing song select filter queries entered via the search box. + /// + public static class FilterQueryParser { private static readonly Regex query_syntax_regex = new Regex( - @"\b(?stars|ar|dr|hp|cs|divisor|length|objects|bpm|status|creator|artist)(?[=:><]+)(?("".*"")|(\S*))", + @"\b(?\w+)(?(:|=|(>|<)(:|=)?))(?("".*"")|(\S*))", RegexOptions.Compiled | RegexOptions.IgnoreCase); internal static void ApplyQueries(FilterCriteria criteria, string query) @@ -19,62 +23,81 @@ namespace osu.Game.Screens.Select foreach (Match match in query_syntax_regex.Matches(query)) { var key = match.Groups["key"].Value.ToLower(); - var op = match.Groups["op"].Value; + var op = parseOperator(match.Groups["op"].Value); var value = match.Groups["value"].Value; - parseKeywordCriteria(criteria, key, value, op); - - query = query.Replace(match.ToString(), ""); + if (tryParseKeywordCriteria(criteria, key, value, op)) + query = query.Replace(match.ToString(), ""); } criteria.SearchText = query; } - private static void parseKeywordCriteria(FilterCriteria criteria, string key, string value, string op) + private static bool tryParseKeywordCriteria(FilterCriteria criteria, string key, string value, Operator op) { switch (key) { - case "stars" when parseFloatWithPoint(value, out var stars): - updateCriteriaRange(ref criteria.StarDifficulty, op, stars, 0.01f / 2); - break; + case "stars": + return TryUpdateCriteriaRange(ref criteria.StarDifficulty, op, value, 0.01d / 2); - case "ar" when parseFloatWithPoint(value, out var ar): - updateCriteriaRange(ref criteria.ApproachRate, op, ar, 0.1f / 2); - break; + case "ar": + return TryUpdateCriteriaRange(ref criteria.ApproachRate, op, value); - case "dr" when parseFloatWithPoint(value, out var dr): - case "hp" when parseFloatWithPoint(value, out dr): - updateCriteriaRange(ref criteria.DrainRate, op, dr, 0.1f / 2); - break; + case "dr": + case "hp": + return TryUpdateCriteriaRange(ref criteria.DrainRate, op, value); - case "cs" when parseFloatWithPoint(value, out var cs): - updateCriteriaRange(ref criteria.CircleSize, op, cs, 0.1f / 2); - break; + case "cs": + return TryUpdateCriteriaRange(ref criteria.CircleSize, op, value); - case "bpm" when parseDoubleWithPoint(value, out var bpm): - updateCriteriaRange(ref criteria.BPM, op, bpm, 0.01d / 2); - break; + case "bpm": + return TryUpdateCriteriaRange(ref criteria.BPM, op, value, 0.01d / 2); - case "length" when parseDoubleWithPoint(value.TrimEnd('m', 's', 'h'), out var length): - var scale = getLengthScale(value); - updateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0); - break; + case "length": + return tryUpdateLengthRange(criteria, op, value); - case "divisor" when parseInt(value, out var divisor): - updateCriteriaRange(ref criteria.BeatDivisor, op, divisor); - break; + case "divisor": + return TryUpdateCriteriaRange(ref criteria.BeatDivisor, op, value, tryParseInt); - case "status" when Enum.TryParse(value, true, out var statusValue): - updateCriteriaRange(ref criteria.OnlineStatus, op, statusValue); - break; + case "status": + return TryUpdateCriteriaRange(ref criteria.OnlineStatus, op, value, + (string s, out BeatmapSetOnlineStatus val) => Enum.TryParse(value, true, out val)); case "creator": - updateCriteriaText(ref criteria.Creator, op, value); - break; + return TryUpdateCriteriaText(ref criteria.Creator, op, value); case "artist": - updateCriteriaText(ref criteria.Artist, op, value); - break; + return TryUpdateCriteriaText(ref criteria.Artist, op, value); + + default: + return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false; + } + } + + private static Operator parseOperator(string value) + { + switch (value) + { + case "=": + case ":": + return Operator.Equal; + + case "<": + return Operator.Less; + + case "<=": + case "<:": + return Operator.LessOrEqual; + + case ">": + return Operator.Greater; + + case ">=": + case ">:": + return Operator.GreaterOrEqual; + + default: + throw new ArgumentOutOfRangeException(nameof(value), $"Unsupported operator {value}"); } } @@ -84,129 +107,203 @@ namespace osu.Game.Screens.Select value.EndsWith('m') ? 60000 : value.EndsWith('h') ? 3600000 : 1000; - private static bool parseFloatWithPoint(string value, out float result) => + private static bool tryParseFloatWithPoint(string value, out float result) => float.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result); - private static bool parseDoubleWithPoint(string value, out double result) => + private static bool tryParseDoubleWithPoint(string value, out double result) => double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result); - private static bool parseInt(string value, out int result) => + private static bool tryParseInt(string value, out int result) => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result); - private static void updateCriteriaText(ref FilterCriteria.OptionalTextFilter textFilter, string op, string value) + /// + /// Attempts to parse a keyword filter with the specified and textual . + /// If the value indicates a valid textual filter, the function returns true and the resulting data is stored into + /// . + /// + /// The to store the parsed data into, if successful. + /// + /// The operator for the keyword filter. + /// Only is valid for textual filters. + /// + /// The value of the keyword filter. + public static bool TryUpdateCriteriaText(ref FilterCriteria.OptionalTextFilter textFilter, Operator op, string value) { switch (op) { - case "=": - case ":": + case Operator.Equal: textFilter.SearchTerm = value.Trim('"'); - break; + return true; + + default: + return false; } } - private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, float value, float tolerance = 0.05f) + /// + /// Attempts to parse a keyword filter of type + /// from the specified and . + /// If can be parsed as a , the function returns true + /// and the resulting range constraint is stored into . + /// + /// + /// The -typed + /// to store the parsed data into, if successful. + /// + /// The operator for the keyword filter. + /// The value of the keyword filter. + /// Allowed tolerance of the parsed range boundary value. + public static bool TryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, string val, float tolerance = 0.05f) + => tryParseFloatWithPoint(val, out float value) && tryUpdateCriteriaRange(ref range, op, value, tolerance); + + private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, float value, float tolerance = 0.05f) { switch (op) { default: - return; + return false; - case "=": - case ":": + case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; break; - case ">": + case Operator.Greater: range.Min = value + tolerance; break; - case ">=": - case ">:": + case Operator.GreaterOrEqual: range.Min = value - tolerance; break; - case "<": + case Operator.Less: range.Max = value - tolerance; break; - case "<=": - case "<:": + case Operator.LessOrEqual: range.Max = value + tolerance; break; } + + return true; } - private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, double value, double tolerance = 0.05) + /// + /// Attempts to parse a keyword filter of type + /// from the specified and . + /// If can be parsed as a , the function returns true + /// and the resulting range constraint is stored into . + /// + /// + /// The -typed + /// to store the parsed data into, if successful. + /// + /// The operator for the keyword filter. + /// The value of the keyword filter. + /// Allowed tolerance of the parsed range boundary value. + public static bool TryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, string val, double tolerance = 0.05) + => tryParseDoubleWithPoint(val, out double value) && tryUpdateCriteriaRange(ref range, op, value, tolerance); + + private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, double value, double tolerance = 0.05) { switch (op) { default: - return; + return false; - case "=": - case ":": + case Operator.Equal: range.Min = value - tolerance; range.Max = value + tolerance; break; - case ">": + case Operator.Greater: range.Min = value + tolerance; break; - case ">=": - case ">:": + case Operator.GreaterOrEqual: range.Min = value - tolerance; break; - case "<": + case Operator.Less: range.Max = value - tolerance; break; - case "<=": - case "<:": + case Operator.LessOrEqual: range.Max = value + tolerance; break; } + + return true; } - private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, T value) + /// + /// Used to determine whether the string value can be converted to type . + /// If conversion can be performed, the delegate returns true + /// and the conversion result is returned in the out parameter . + /// + /// The string value to attempt parsing for. + /// The parsed value, if conversion is possible. + public delegate bool TryParseFunction(string val, out T parsed); + + /// + /// Attempts to parse a keyword filter of type , + /// from the specified and . + /// If can be parsed into using , the function returns true + /// and the resulting range constraint is stored into . + /// + /// The to store the parsed data into, if successful. + /// The operator for the keyword filter. + /// The value of the keyword filter. + /// Function used to determine if can be converted to type . + public static bool TryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, string val, TryParseFunction parseFunction) + where T : struct + => parseFunction.Invoke(val, out var converted) && tryUpdateCriteriaRange(ref range, op, converted); + + private static bool tryUpdateCriteriaRange(ref FilterCriteria.OptionalRange range, Operator op, T value) where T : struct { switch (op) { default: - return; + return false; - case "=": - case ":": + case Operator.Equal: range.IsLowerInclusive = range.IsUpperInclusive = true; range.Min = value; range.Max = value; break; - case ">": + case Operator.Greater: range.IsLowerInclusive = false; range.Min = value; break; - case ">=": - case ">:": + case Operator.GreaterOrEqual: range.IsLowerInclusive = true; range.Min = value; break; - case "<": + case Operator.Less: range.IsUpperInclusive = false; range.Max = value; break; - case "<=": - case "<:": + case Operator.LessOrEqual: range.IsUpperInclusive = true; range.Max = value; break; } + + return true; + } + + private static bool tryUpdateLengthRange(FilterCriteria criteria, Operator op, string val) + { + if (!tryParseDoubleWithPoint(val.TrimEnd('m', 's', 'h'), out var length)) + return false; + + var scale = getLengthScale(val); + return tryUpdateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0); } } } diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index 35970cd960..afb3943a09 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -4,26 +4,29 @@ using System; using osuTK; using osuTK.Graphics; -using osuTK.Input; 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.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; +using osu.Framework.Input.Bindings; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Select { - public class FooterButton : OsuClickableContainer + public class FooterButton : OsuClickableContainer, IKeyBindingHandler { public const float SHEAR_WIDTH = 7.5f; protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / Footer.HEIGHT, 0); - public string Text + public LocalisableString Text { - get => SpriteText?.Text; + get => SpriteText?.Text ?? default; set { if (SpriteText != null) @@ -63,6 +66,7 @@ namespace osu.Game.Screens.Select private readonly Box light; public FooterButton() + : base(HoverSampleSet.SongSelect) { AutoSizeAxes = Axes.Both; Shear = SHEAR; @@ -104,6 +108,7 @@ namespace osu.Game.Screens.Select AutoSizeAxes = Axes.Both, Child = SpriteText = new OsuSpriteText { + AlwaysPresent = true, Anchor = Anchor.Centre, Origin = Anchor.Centre, } @@ -117,7 +122,7 @@ namespace osu.Game.Screens.Select public Action Hovered; public Action HoverLost; - public Key? Hotkey; + public GlobalAction? Hotkey; protected override void UpdateAfterChildren() { @@ -167,15 +172,17 @@ namespace osu.Game.Screens.Select return base.OnClick(e); } - protected override bool OnKeyDown(KeyDownEvent e) + public virtual bool OnPressed(GlobalAction action) { - if (!e.Repeat && e.Key == Hotkey) + if (action == Hotkey) { Click(); return true; } - return base.OnKeyDown(e); + return false; } + + public virtual void OnReleased(GlobalAction action) { } } } diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index 02333da0dc..b98b48a0c0 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -14,7 +14,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; -using osuTK.Input; +using osu.Game.Input.Bindings; namespace osu.Game.Screens.Select { @@ -57,7 +57,7 @@ namespace osu.Game.Screens.Select lowMultiplierColour = colours.Red; highMultiplierColour = colours.Green; Text = @"mods"; - Hotkey = Key.F1; + Hotkey = GlobalAction.ToggleModSelection; } protected override void LoadComplete() diff --git a/osu.Game/Screens/Select/FooterButtonOptions.cs b/osu.Game/Screens/Select/FooterButtonOptions.cs index c000d8a8c8..e549656785 100644 --- a/osu.Game/Screens/Select/FooterButtonOptions.cs +++ b/osu.Game/Screens/Select/FooterButtonOptions.cs @@ -4,7 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Graphics; -using osuTK.Input; +using osu.Game.Input.Bindings; namespace osu.Game.Screens.Select { @@ -16,7 +16,7 @@ namespace osu.Game.Screens.Select SelectedColour = colours.Blue; DeselectedColour = SelectedColour.Opacity(0.5f); Text = @"options"; - Hotkey = Key.F3; + Hotkey = GlobalAction.ToggleBeatmapOptions; } } } diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index a42e6721db..2d14111137 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -1,35 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osuTK.Input; +using osu.Game.Input.Bindings; +using osuTK; namespace osu.Game.Screens.Select { public class FooterButtonRandom : FooterButton { - private readonly SpriteText secondaryText; - private bool secondaryActive; + public Action NextRandom { get; set; } + public Action PreviousRandom { get; set; } - public FooterButtonRandom() - { - TextContainer.Add(secondaryText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = @"rewind", - Alpha = 0, - }); - - // force both text sprites to always be present to avoid width flickering while they're being swapped out - SpriteText.AlwaysPresent = secondaryText.AlwaysPresent = true; - } + private bool rewindSearch; [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -37,34 +25,57 @@ namespace osu.Game.Screens.Select SelectedColour = colours.Green; DeselectedColour = SelectedColour.Opacity(0.5f); Text = @"random"; - Hotkey = Key.F2; - } - protected override bool OnKeyDown(KeyDownEvent e) - { - secondaryActive = e.ShiftPressed; - updateText(); - return base.OnKeyDown(e); - } - - protected override void OnKeyUp(KeyUpEvent e) - { - secondaryActive = e.ShiftPressed; - updateText(); - base.OnKeyUp(e); - } - - private void updateText() - { - if (secondaryActive) + Action = () => { - SpriteText.FadeOut(120, Easing.InQuad); - secondaryText.FadeIn(120, Easing.InQuad); + if (rewindSearch) + { + const double fade_time = 500; + + OsuSpriteText rewindSpriteText; + + TextContainer.Add(rewindSpriteText = new OsuSpriteText + { + Alpha = 0, + Text = @"rewind", + AlwaysPresent = true, // make sure the button is sized large enough to always show this + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + rewindSpriteText.FadeOutFromOne(fade_time, Easing.In); + rewindSpriteText.MoveTo(Vector2.Zero).MoveTo(new Vector2(0, 10), fade_time, Easing.In); + rewindSpriteText.Expire(); + + SpriteText.FadeInFromZero(fade_time, Easing.In); + + PreviousRandom.Invoke(); + } + else + { + NextRandom.Invoke(); + } + }; + } + + public override bool OnPressed(GlobalAction action) + { + rewindSearch = action == GlobalAction.SelectPreviousRandom; + + if (action != GlobalAction.SelectNextRandom && action != GlobalAction.SelectPreviousRandom) + { + return false; } - else + + Click(); + return true; + } + + public override void OnReleased(GlobalAction action) + { + if (action == GlobalAction.SelectPreviousRandom) { - SpriteText.FadeIn(120, Easing.InQuad); - secondaryText.FadeOut(120, Easing.InQuad); + rewindSearch = false; } } } diff --git a/osu.Game/Screens/Select/ImportFromStablePopup.cs b/osu.Game/Screens/Select/ImportFromStablePopup.cs index 8dab83b24c..d8137432bd 100644 --- a/osu.Game/Screens/Select/ImportFromStablePopup.cs +++ b/osu.Game/Screens/Select/ImportFromStablePopup.cs @@ -12,7 +12,7 @@ namespace osu.Game.Screens.Select public ImportFromStablePopup(Action importFromStable) { HeaderText = @"You have no beatmaps!"; - BodyText = "An existing copy of osu! was found, though.\nWould you like to import your beatmaps, skins, collections and scores?\nThis will create a second copy of all files on disk."; + BodyText = "Would you like to import your beatmaps, skins, collections and scores from an existing osu!stable installation?\nThis will create a second copy of all files on disk."; Icon = FontAwesome.Solid.Plane; diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs index 6e2f3cc9df..845c0a914e 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Effects; 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.Sprites; using osuTK; @@ -39,13 +40,13 @@ namespace osu.Game.Screens.Select.Options set => iconText.Icon = value; } - public string FirstLineText + public LocalisableString FirstLineText { get => firstLine.Text; set => firstLine.Text = value; } - public string SecondLineText + public LocalisableString SecondLineText { get => secondLine.Text; set => secondLine.Text = value; diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index e61d5cce85..dfb4b59060 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -106,7 +106,7 @@ namespace osu.Game.Screens.Select SampleConfirm?.Play(); - this.Push(player = new PlayerLoader(() => new Player())); + this.Push(player = new PlayerLoader(() => new SoloPlayer())); return true; } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index b7f7c40539..270addc8e6 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -22,7 +22,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Edit; using osu.Game.Screens.Menu; using osu.Game.Screens.Select.Options; -using osu.Game.Skinning; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -35,9 +34,9 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Game.Collections; using osu.Game.Graphics.UserInterface; -using osu.Game.Scoring; using System.Diagnostics; using osu.Game.Screens.Play; +using osu.Game.Database; namespace osu.Game.Screens.Select { @@ -52,6 +51,8 @@ namespace osu.Game.Screens.Select protected virtual bool ShowFooter => true; + protected virtual bool DisplayStableImportPrompt => stableImportManager?.SupportsImportFromStable == true; + /// /// Can be null if is false. /// @@ -84,6 +85,9 @@ namespace osu.Game.Screens.Select [Resolved] private BeatmapManager beatmaps { get; set; } + [Resolved(CanBeNull = true)] + private StableImportManager stableImportManager { get; set; } + protected ModSelectOverlay ModSelect { get; private set; } protected Sample SampleConfirm { get; private set; } @@ -101,7 +105,7 @@ namespace osu.Game.Screens.Select private MusicController music { get; set; } [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores, CollectionManager collections, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender) + private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender) { // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). transferRulesetValue(); @@ -282,18 +286,12 @@ namespace osu.Game.Screens.Select { Schedule(() => { - // if we have no beatmaps but osu-stable is found, let's prompt the user to import. - if (!beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.Minimal).Any() && beatmaps.StableInstallationAvailable) + // if we have no beatmaps, let's prompt the user to import from over a stable install if he has one. + if (!beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.Minimal).Any() && DisplayStableImportPrompt) { dialogOverlay.Push(new ImportFromStablePopup(() => { - Task.Run(beatmaps.ImportFromStableAsync) - .ContinueWith(_ => - { - Task.Run(scores.ImportFromStableAsync); - Task.Run(collections.ImportFromStableAsync); - }, TaskContinuationOptions.OnlyOnRanToCompletion); - Task.Run(skins.ImportFromStableAsync); + Task.Run(() => stableImportManager.ImportFromStableAsync(StableContent.All)); })); } }); @@ -307,7 +305,11 @@ namespace osu.Game.Screens.Select protected virtual IEnumerable<(FooterButton, OverlayContainer)> CreateFooterButtons() => new (FooterButton, OverlayContainer)[] { (new FooterButtonMods { Current = Mods }, ModSelect), - (new FooterButtonRandom { Action = triggerRandom }, null), + (new FooterButtonRandom + { + NextRandom = () => Carousel.SelectNextRandom(), + PreviousRandom = Carousel.SelectPreviousRandom + }, null), (new FooterButtonOptions(), BeatmapOptions) }; @@ -503,12 +505,13 @@ namespace osu.Game.Screens.Select { Logger.Log($"beatmap changed from \"{Beatmap.Value.BeatmapInfo}\" to \"{beatmap}\""); - WorkingBeatmap previous = Beatmap.Value; - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap, previous); + int? lastSetID = Beatmap.Value?.BeatmapInfo.BeatmapSetInfoID; + + Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap); if (beatmap != null) { - if (beatmap.BeatmapSetInfoID == previous?.BeatmapInfo.BeatmapSetInfoID) + if (beatmap.BeatmapSetInfoID == lastSetID) sampleChangeDifficulty.Play(); else sampleChangeBeatmap.Play(); @@ -522,14 +525,6 @@ namespace osu.Game.Screens.Select } } - private void triggerRandom() - { - if (GetContainingInputManager().CurrentState.Keyboard.ShiftPressed) - Carousel.SelectPreviousRandom(); - else - Carousel.SelectNextRandom(); - } - public override void OnEntering(IScreen last) { base.OnEntering(last); diff --git a/osu.Game/Screens/Spectate/GameplayState.cs b/osu.Game/Screens/Spectate/GameplayState.cs new file mode 100644 index 0000000000..4579b9c07c --- /dev/null +++ b/osu.Game/Screens/Spectate/GameplayState.cs @@ -0,0 +1,37 @@ +// 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; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Spectate +{ + /// + /// The gameplay state of a spectated user. This class is immutable. + /// + public class GameplayState + { + /// + /// The score which the user is playing. + /// + public readonly Score Score; + + /// + /// The ruleset which the user is playing. + /// + public readonly Ruleset Ruleset; + + /// + /// The beatmap which the user is playing. + /// + public readonly WorkingBeatmap Beatmap; + + public GameplayState(Score score, Ruleset ruleset, WorkingBeatmap beatmap) + { + Score = score; + Ruleset = ruleset; + Beatmap = beatmap; + } + } +} diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs new file mode 100644 index 0000000000..9a20bb58b8 --- /dev/null +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -0,0 +1,272 @@ +// 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.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.Spectator; +using osu.Game.Replays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Screens.Spectate +{ + /// + /// A which spectates one or more users. + /// + public abstract class SpectatorScreen : OsuScreen + { + protected IReadOnlyList UserIds => userIds; + + private readonly List userIds = new List(); + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + [Resolved] + private SpectatorClient spectatorClient { get; set; } + + [Resolved] + private UserLookupCache userLookupCache { get; set; } + + private readonly IBindableDictionary playingUserStates = new BindableDictionary(); + + private readonly Dictionary userMap = new Dictionary(); + private readonly Dictionary gameplayStates = new Dictionary(); + + private IBindable> managerUpdated; + + /// + /// Creates a new . + /// + /// The users to spectate. + protected SpectatorScreen(params int[] userIds) + { + this.userIds.AddRange(userIds); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + getAllUsers().ContinueWith(users => Schedule(() => + { + foreach (var u in users.Result) + userMap[u.Id] = u; + + playingUserStates.BindTo(spectatorClient.PlayingUserStates); + playingUserStates.BindCollectionChanged(onPlayingUserStatesChanged, true); + + spectatorClient.OnNewFrames += userSentFrames; + + managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(beatmapUpdated); + + foreach (var (id, _) in userMap) + spectatorClient.WatchUser(id); + })); + } + + private Task getAllUsers() + { + var userLookupTasks = new List>(); + + foreach (var u in userIds) + { + userLookupTasks.Add(userLookupCache.GetUserAsync(u).ContinueWith(task => + { + if (!task.IsCompletedSuccessfully) + return null; + + return task.Result; + })); + } + + return Task.WhenAll(userLookupTasks); + } + + private void beatmapUpdated(ValueChangedEvent> e) + { + if (!e.NewValue.TryGetTarget(out var beatmapSet)) + return; + + foreach (var (userId, _) in userMap) + { + if (!playingUserStates.TryGetValue(userId, out var userState)) + continue; + + if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == userState.BeatmapID)) + updateGameplayState(userId); + } + } + + private void onPlayingUserStatesChanged(object sender, NotifyDictionaryChangedEventArgs e) + { + switch (e.Action) + { + case NotifyDictionaryChangedAction.Add: + foreach (var (userId, state) in e.NewItems.AsNonNull()) + onUserStateAdded(userId, state); + break; + + case NotifyDictionaryChangedAction.Remove: + foreach (var (userId, _) in e.OldItems.AsNonNull()) + onUserStateRemoved(userId); + break; + + case NotifyDictionaryChangedAction.Replace: + foreach (var (userId, _) in e.OldItems.AsNonNull()) + onUserStateRemoved(userId); + + foreach (var (userId, state) in e.NewItems.AsNonNull()) + onUserStateAdded(userId, state); + break; + } + } + + private void onUserStateAdded(int userId, SpectatorState state) + { + if (state.RulesetID == null || state.BeatmapID == null) + return; + + if (!userMap.ContainsKey(userId)) + return; + + Schedule(() => OnUserStateChanged(userId, state)); + updateGameplayState(userId); + } + + private void onUserStateRemoved(int userId) + { + if (!userMap.ContainsKey(userId)) + return; + + if (!gameplayStates.TryGetValue(userId, out var gameplayState)) + return; + + gameplayState.Score.Replay.HasReceivedAllFrames = true; + + gameplayStates.Remove(userId); + Schedule(() => EndGameplay(userId)); + } + + private void updateGameplayState(int userId) + { + Debug.Assert(userMap.ContainsKey(userId)); + + var user = userMap[userId]; + var spectatorState = playingUserStates[userId]; + + var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance(); + if (resolvedRuleset == null) + return; + + var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == spectatorState.BeatmapID); + if (resolvedBeatmap == null) + return; + + var score = new Score + { + ScoreInfo = new ScoreInfo + { + Beatmap = resolvedBeatmap, + User = user, + Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(), + Ruleset = resolvedRuleset.RulesetInfo, + }, + Replay = new Replay { HasReceivedAllFrames = false }, + }; + + var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap)); + + gameplayStates[userId] = gameplayState; + Schedule(() => StartGameplay(userId, gameplayState)); + } + + private void userSentFrames(int userId, FrameDataBundle bundle) + { + if (!userMap.ContainsKey(userId)) + return; + + if (!gameplayStates.TryGetValue(userId, out var gameplayState)) + return; + + // The ruleset instance should be guaranteed to be in sync with the score via ScoreLock. + Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset)); + + foreach (var frame in bundle.Frames) + { + IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame(); + convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap); + + var convertedFrame = (ReplayFrame)convertibleFrame; + convertedFrame.Time = frame.Time; + + gameplayState.Score.Replay.Frames.Add(convertedFrame); + } + } + + /// + /// Invoked when a spectated user's state has changed. + /// + /// The user whose state has changed. + /// The new state. + protected abstract void OnUserStateChanged(int userId, [NotNull] SpectatorState spectatorState); + + /// + /// Starts gameplay for a user. + /// + /// The user to start gameplay for. + /// The gameplay state. + protected abstract void StartGameplay(int userId, [NotNull] GameplayState gameplayState); + + /// + /// Ends gameplay for a user. + /// + /// The user to end gameplay for. + protected abstract void EndGameplay(int userId); + + /// + /// Stops spectating a user. + /// + /// The user to stop spectating. + protected void RemoveUser(int userId) + { + onUserStateRemoved(userId); + + userIds.Remove(userId); + userMap.Remove(userId); + + spectatorClient.StopWatchingUser(userId); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (spectatorClient != null) + { + spectatorClient.OnNewFrames -= userSentFrames; + + foreach (var (userId, _) in userMap) + spectatorClient.StopWatchingUser(userId); + } + + managerUpdated?.UnbindAll(); + } + } +} diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs index 4027cc650d..30192182f3 100644 --- a/osu.Game/Skinning/DefaultLegacySkin.cs +++ b/osu.Game/Skinning/DefaultLegacySkin.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using JetBrains.Annotations; using osu.Framework.IO.Stores; +using osu.Game.Extensions; using osu.Game.IO; using osuTK.Graphics; @@ -9,8 +11,14 @@ namespace osu.Game.Skinning { public class DefaultLegacySkin : LegacySkin { - public DefaultLegacySkin(IResourceStore storage, IStorageResourceProvider resources) - : base(Info, storage, resources, string.Empty) + public DefaultLegacySkin(IStorageResourceProvider resources) + : this(Info, resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public DefaultLegacySkin(SkinInfo skin, IStorageResourceProvider resources) + : base(skin, new NamespacedResourceStore(resources.Resources, "Skins/Legacy"), resources, string.Empty) { Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255); Configuration.AddComboColours( @@ -27,7 +35,8 @@ namespace osu.Game.Skinning { ID = SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon. Name = "osu!classic", - Creator = "team osu!" + Creator = "team osu!", + InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo() }; } } diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 346c7b3c65..710ed5ffc4 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -2,29 +2,124 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; +using osu.Game.Extensions; +using osu.Game.IO; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.HitErrorMeters; +using osuTK; using osuTK.Graphics; namespace osu.Game.Skinning { public class DefaultSkin : Skin { - public DefaultSkin() - : base(SkinInfo.Default) + public DefaultSkin(IStorageResourceProvider resources) + : this(SkinInfo.Default, resources) + { + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] + public DefaultSkin(SkinInfo skin, IStorageResourceProvider resources) + : base(skin, resources) { Configuration = new DefaultSkinConfiguration(); } - public override Drawable GetDrawableComponent(ISkinComponent component) => null; - public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; - public override Sample GetSample(ISampleInfo sampleInfo) => null; + public override ISample GetSample(ISampleInfo sampleInfo) => null; + + public override Drawable GetDrawableComponent(ISkinComponent component) + { + if (base.GetDrawableComponent(component) is Drawable c) + return c; + + switch (component) + { + case SkinnableTargetComponent target: + switch (target.Target) + { + case SkinnableTarget.MainHUDComponents: + var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => + { + var score = container.OfType().FirstOrDefault(); + var accuracy = container.OfType().FirstOrDefault(); + var combo = container.OfType().FirstOrDefault(); + + if (score != null) + { + score.Anchor = Anchor.TopCentre; + score.Origin = Anchor.TopCentre; + + // elements default to beneath the health bar + const float vertical_offset = 30; + + const float horizontal_padding = 20; + + score.Position = new Vector2(0, vertical_offset); + + if (accuracy != null) + { + accuracy.Position = new Vector2(-accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 - horizontal_padding, vertical_offset + 5); + accuracy.Origin = Anchor.TopRight; + accuracy.Anchor = Anchor.TopCentre; + } + + if (combo != null) + { + combo.Position = new Vector2(accuracy.ScreenSpaceDeltaToParentSpace(score.ScreenSpaceDrawQuad.Size).X / 2 + horizontal_padding, vertical_offset + 5); + combo.Anchor = Anchor.TopCentre; + } + + var hitError = container.OfType().FirstOrDefault(); + + if (hitError != null) + { + hitError.Anchor = Anchor.CentreLeft; + hitError.Origin = Anchor.CentreLeft; + } + + var hitError2 = container.OfType().LastOrDefault(); + + if (hitError2 != null) + { + hitError2.Anchor = Anchor.CentreRight; + hitError2.Scale = new Vector2(-1, 1); + // origin flipped to match scale above. + hitError2.Origin = Anchor.CentreLeft; + } + } + }) + { + Children = new Drawable[] + { + new DefaultComboCounter(), + new DefaultScoreCounter(), + new DefaultAccuracyCounter(), + new DefaultHealthDisplay(), + new SongProgress(), + new BarHitErrorMeter(), + new BarHitErrorMeter(), + } + }; + + return skinnableTargetWrapper; + } + + break; + } + + return null; + } public override IBindable GetConfig(TLookup lookup) { diff --git a/osu.Game/Skinning/Editor/SkinBlueprint.cs b/osu.Game/Skinning/Editor/SkinBlueprint.cs new file mode 100644 index 0000000000..0a4bd1d75f --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinBlueprint.cs @@ -0,0 +1,185 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Edit; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning.Editor +{ + public class SkinBlueprint : SelectionBlueprint + { + private Container box; + + private Container outlineBox; + + private AnchorOriginVisualiser anchorOriginVisualiser; + + private Drawable drawable => (Drawable)Item; + + protected override bool ShouldBeAlive => drawable.IsAlive && Item.IsPresent; + + [Resolved] + private OsuColour colours { get; set; } + + public SkinBlueprint(ISkinnableDrawable component) + : base(component) + { + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + box = new Container + { + Children = new Drawable[] + { + outlineBox = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 3, + BorderColour = Color4.White, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0f, + AlwaysPresent = true, + }, + } + }, + new OsuSpriteText + { + Text = Item.GetType().Name, + Font = OsuFont.Default.With(size: 10, weight: FontWeight.Bold), + Anchor = Anchor.BottomRight, + Origin = Anchor.TopRight, + }, + }, + }, + anchorOriginVisualiser = new AnchorOriginVisualiser(drawable) + { + Alpha = 0, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateSelectedState(); + this.FadeInFromZero(200, Easing.OutQuint); + } + + protected override void OnSelected() + { + // base logic hides selected blueprints when not selected, but skin blueprints don't do that. + updateSelectedState(); + } + + protected override void OnDeselected() + { + // base logic hides selected blueprints when not selected, but skin blueprints don't do that. + updateSelectedState(); + } + + private void updateSelectedState() + { + outlineBox.FadeColour(colours.Pink.Opacity(IsSelected ? 1 : 0.5f), 200, Easing.OutQuint); + outlineBox.Child.FadeTo(IsSelected ? 0.2f : 0, 200, Easing.OutQuint); + + anchorOriginVisualiser.FadeTo(IsSelected ? 1 : 0, 200, Easing.OutQuint); + } + + private Quad drawableQuad; + + public override Quad ScreenSpaceDrawQuad => drawableQuad; + + protected override void Update() + { + base.Update(); + + drawableQuad = drawable.ScreenSpaceDrawQuad; + var quad = ToLocalSpace(drawable.ScreenSpaceDrawQuad); + + box.Position = drawable.ToSpaceOfOtherDrawable(Vector2.Zero, this); + box.Size = quad.Size; + box.Rotation = drawable.Rotation; + box.Scale = new Vector2(MathF.Sign(drawable.Scale.X), MathF.Sign(drawable.Scale.Y)); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => drawable.ReceivePositionalInputAt(screenSpacePos); + + public override Vector2 ScreenSpaceSelectionPoint => drawable.ToScreenSpace(drawable.OriginPosition); + + public override Quad SelectionQuad => drawable.ScreenSpaceDrawQuad; + } + + internal class AnchorOriginVisualiser : CompositeDrawable + { + private readonly Drawable drawable; + + private readonly Box originBox; + + private readonly Box anchorBox; + private readonly Box anchorLine; + + public AnchorOriginVisualiser(Drawable drawable) + { + this.drawable = drawable; + + InternalChildren = new Drawable[] + { + anchorLine = new Box + { + Colour = Color4.Yellow, + Height = 2, + }, + originBox = new Box + { + Colour = Color4.Red, + Origin = Anchor.Centre, + Size = new Vector2(5), + }, + anchorBox = new Box + { + Colour = Color4.Red, + Origin = Anchor.Centre, + Size = new Vector2(5), + }, + }; + } + + protected override void Update() + { + base.Update(); + + if (drawable.Parent == null) + return; + + originBox.Position = drawable.ToSpaceOfOtherDrawable(drawable.OriginPosition, this); + anchorBox.Position = drawable.Parent.ToSpaceOfOtherDrawable(drawable.AnchorPosition, this); + + var point1 = ToLocalSpace(anchorBox.ScreenSpaceDrawQuad.Centre); + var point2 = ToLocalSpace(originBox.ScreenSpaceDrawQuad.Centre); + + anchorLine.Position = point1; + anchorLine.Width = (point2 - point1).Length; + anchorLine.Rotation = MathHelper.RadiansToDegrees(MathF.Atan2(point2.Y - point1.Y, point2.X - point1.X)); + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs new file mode 100644 index 0000000000..c0cc2ab40e --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs @@ -0,0 +1,97 @@ +// 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.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Skinning.Editor +{ + public class SkinBlueprintContainer : BlueprintContainer + { + private readonly Drawable target; + + private readonly List> targetComponents = new List>(); + + public SkinBlueprintContainer(Drawable target) + { + this.target = target; + } + + [BackgroundDependencyLoader(true)] + private void load(SkinEditor editor) + { + SelectedItems.BindTo(editor.SelectedComponents); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // track each target container on the current screen. + var targetContainers = target.ChildrenOfType().ToArray(); + + if (targetContainers.Length == 0) + { + var targetScreen = target.ChildrenOfType().LastOrDefault()?.GetType().Name ?? "this screen"; + + AddInternal(new ScreenWhiteBox.UnderConstructionMessage(targetScreen, "doesn't support skin customisation just yet.")); + return; + } + + foreach (var targetContainer in targetContainers) + { + var bindableList = new BindableList { BindTarget = targetContainer.Components }; + bindableList.BindCollectionChanged(componentsChanged, true); + + targetComponents.Add(bindableList); + } + } + + private void componentsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var item in e.NewItems.Cast()) + AddBlueprintFor(item); + break; + + case NotifyCollectionChangedAction.Remove: + case NotifyCollectionChangedAction.Reset: + foreach (var item in e.OldItems.Cast()) + RemoveBlueprintFor(item); + break; + + case NotifyCollectionChangedAction.Replace: + foreach (var item in e.OldItems.Cast()) + RemoveBlueprintFor(item); + + foreach (var item in e.NewItems.Cast()) + AddBlueprintFor(item); + break; + } + } + + protected override void AddBlueprintFor(ISkinnableDrawable item) + { + if (!item.IsEditable) + return; + + base.AddBlueprintFor(item); + } + + protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); + + protected override SelectionBlueprint CreateBlueprintFor(ISkinnableDrawable component) + => new SkinBlueprint(component); + } +} diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs new file mode 100644 index 0000000000..935d2756fb --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs @@ -0,0 +1,173 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning.Editor +{ + public class SkinComponentToolbox : ScrollingToolboxGroup + { + public Action RequestPlacement; + + private const float component_display_scale = 0.8f; + + [Cached] + private ScoreProcessor scoreProcessor = new ScoreProcessor + { + Combo = { Value = RNG.Next(1, 1000) }, + TotalScore = { Value = RNG.Next(1000, 10000000) } + }; + + [Cached(typeof(HealthProcessor))] + private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + + public SkinComponentToolbox(float height) + : base("Components", height) + { + RelativeSizeAxes = Axes.None; + Width = 200; + } + + [BackgroundDependencyLoader] + private void load() + { + FillFlowContainer fill; + + Child = fill = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20) + }; + + var skinnableTypes = typeof(OsuGame).Assembly.GetTypes() + .Where(t => !t.IsInterface) + .Where(t => typeof(ISkinnableDrawable).IsAssignableFrom(t)) + .ToArray(); + + foreach (var type in skinnableTypes) + { + var component = attemptAddComponent(type); + + if (component != null) + { + component.RequestPlacement = t => RequestPlacement?.Invoke(t); + fill.Add(component); + } + } + } + + private static ToolboxComponentButton attemptAddComponent(Type type) + { + try + { + var instance = (Drawable)Activator.CreateInstance(type); + + Debug.Assert(instance != null); + + if (!((ISkinnableDrawable)instance).IsEditable) + return null; + + return new ToolboxComponentButton(instance); + } + catch + { + return null; + } + } + + private class ToolboxComponentButton : OsuButton + { + protected override bool ShouldBeConsideredForInput(Drawable child) => false; + + public override bool PropagateNonPositionalInputSubTree => false; + + private readonly Drawable component; + + public Action RequestPlacement; + + private Container innerContainer; + + public ToolboxComponentButton(Drawable component) + { + this.component = component; + + Enabled.Value = true; + + RelativeSizeAxes = Axes.X; + Height = 70; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundColour = colours.Gray3; + Content.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 2, + Offset = new Vector2(0, 1), + Colour = Color4.Black.Opacity(0.5f) + }; + + AddRange(new Drawable[] + { + new OsuSpriteText + { + Text = component.GetType().Name, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + innerContainer = new Container + { + Y = 10, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(component_display_scale), + Masking = true, + Child = component + } + }); + + // adjust provided component to fit / display in a known state. + component.Anchor = Anchor.Centre; + component.Origin = Anchor.Centre; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (component.RelativeSizeAxes != Axes.None) + { + innerContainer.AutoSizeAxes = Axes.None; + innerContainer.Height = 100; + } + } + + protected override bool OnClick(ClickEvent e) + { + RequestPlacement?.Invoke(component.GetType()); + return true; + } + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs new file mode 100644 index 0000000000..07a94cac7a --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -0,0 +1,250 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Skinning.Editor +{ + [Cached(typeof(SkinEditor))] + public class SkinEditor : VisibilityContainer + { + public const double TRANSITION_DURATION = 500; + + public readonly BindableList SelectedComponents = new BindableList(); + + protected override bool StartHidden => true; + + private readonly Drawable targetScreen; + + private OsuTextFlowContainer headerText; + + private Bindable currentSkin; + + [Resolved] + private SkinManager skins { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + private bool hasBegunMutating; + + public SkinEditor(Drawable targetScreen) + { + this.targetScreen = targetScreen; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + headerText = new OsuTextFlowContainer + { + TextAnchor = Anchor.TopCentre, + Padding = new MarginPadding(20), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new SkinComponentToolbox(600) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RequestPlacement = placeComponent + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new SkinBlueprintContainer(targetScreen), + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Spacing = new Vector2(5), + Padding = new MarginPadding + { + Top = 10, + Left = 10, + }, + Margin = new MarginPadding + { + Right = 10, + Bottom = 10, + }, + Children = new Drawable[] + { + new TriangleButton + { + Text = "Save Changes", + Width = 140, + Action = Save, + }, + new DangerousTriangleButton + { + Text = "Revert to default", + Width = 140, + Action = revert, + }, + } + }, + } + }, + } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Show(); + + // as long as the skin editor is loaded, let's make sure we can modify the current skin. + currentSkin = skins.CurrentSkin.GetBoundCopy(); + + // schedule ensures this only happens when the skin editor is visible. + // also avoid some weird endless recursion / bindable feedback loop (something to do with tracking skins across three different bindable types). + // probably something which will be factored out in a future database refactor so not too concerning for now. + currentSkin.BindValueChanged(skin => + { + hasBegunMutating = false; + Scheduler.AddOnce(skinChanged); + }, true); + } + + private void skinChanged() + { + headerText.Clear(); + + headerText.AddParagraph("Skin editor", cp => cp.Font = OsuFont.Default.With(size: 24)); + headerText.NewParagraph(); + headerText.AddText("Currently editing ", cp => + { + cp.Font = OsuFont.Default.With(size: 12); + cp.Colour = colours.Yellow; + }); + + headerText.AddText($"{currentSkin.Value.SkinInfo}", cp => + { + cp.Font = OsuFont.Default.With(size: 12, weight: FontWeight.Bold); + cp.Colour = colours.Yellow; + }); + + skins.EnsureMutableSkin(); + hasBegunMutating = true; + } + + private void placeComponent(Type type) + { + var targetContainer = getTarget(SkinnableTarget.MainHUDComponents); + + if (targetContainer == null) + return; + + if (!(Activator.CreateInstance(type) is ISkinnableDrawable component)) + throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISkinnableDrawable)}."); + + var drawableComponent = (Drawable)component; + + // give newly added components a sane starting location. + drawableComponent.Origin = Anchor.TopCentre; + drawableComponent.Anchor = Anchor.TopCentre; + drawableComponent.Y = targetContainer.DrawSize.Y / 2; + + targetContainer.Add(component); + + SelectedComponents.Clear(); + SelectedComponents.Add(component); + } + + private IEnumerable availableTargets => targetScreen.ChildrenOfType(); + + private ISkinnableTarget getTarget(SkinnableTarget target) + { + return availableTargets.FirstOrDefault(c => c.Target == target); + } + + private void revert() + { + ISkinnableTarget[] targetContainers = availableTargets.ToArray(); + + foreach (var t in targetContainers) + { + currentSkin.Value.ResetDrawableTarget(t); + + // add back default components + getTarget(t.Target).Reload(); + } + } + + public void Save() + { + if (!hasBegunMutating) + return; + + ISkinnableTarget[] targetContainers = availableTargets.ToArray(); + + foreach (var t in targetContainers) + currentSkin.Value.UpdateDrawableTarget(t); + + skins.Save(skins.CurrentSkin.Value); + } + + protected override bool OnHover(HoverEvent e) => true; + + protected override bool OnMouseDown(MouseDownEvent e) => true; + + protected override void PopIn() + { + this.FadeIn(TRANSITION_DURATION, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(TRANSITION_DURATION, Easing.OutQuint); + } + + public void DeleteItems(ISkinnableDrawable[] items) + { + foreach (var item in items) + availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item); + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs new file mode 100644 index 0000000000..88020896bb --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -0,0 +1,99 @@ +// 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.Input.Bindings; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; + +namespace osu.Game.Skinning.Editor +{ + /// + /// A container which handles loading a skin editor on user request for a specified target. + /// This also handles the scaling / positioning adjustment of the target. + /// + public class SkinEditorOverlay : CompositeDrawable, IKeyBindingHandler + { + private readonly ScalingContainer target; + private SkinEditor skinEditor; + + public const float VISIBLE_TARGET_SCALE = 0.8f; + + [Resolved] + private OsuColour colours { get; set; } + + public SkinEditorOverlay(ScalingContainer target) + { + this.target = target; + RelativeSizeAxes = Axes.Both; + } + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.Back: + if (skinEditor?.State.Value == Visibility.Visible) + { + skinEditor.ToggleVisibility(); + return true; + } + + break; + + case GlobalAction.ToggleSkinEditor: + if (skinEditor == null) + { + LoadComponentAsync(skinEditor = new SkinEditor(target), AddInternal); + skinEditor.State.BindValueChanged(editorVisibilityChanged); + } + else + skinEditor.ToggleVisibility(); + + return true; + } + + return false; + } + + private void editorVisibilityChanged(ValueChangedEvent visibility) + { + if (visibility.NewValue == Visibility.Visible) + { + target.Masking = true; + target.AllowScaling = false; + target.RelativePositionAxes = Axes.Both; + + target.ScaleTo(VISIBLE_TARGET_SCALE, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); + target.MoveToX(0.095f, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); + } + else + { + target.AllowScaling = true; + + target.ScaleTo(1, SkinEditor.TRANSITION_DURATION, Easing.OutQuint).OnComplete(_ => target.Masking = false); + target.MoveToX(0f, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); + } + } + + public void OnReleased(GlobalAction action) + { + } + + /// + /// Exit any existing skin editor due to the game state changing. + /// + public void Reset() + { + skinEditor?.Save(); + skinEditor?.Hide(); + skinEditor?.Expire(); + + skinEditor = null; + } + } +} diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs new file mode 100644 index 0000000000..9cca0ba2c7 --- /dev/null +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -0,0 +1,260 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Utils; +using osu.Game.Extensions; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Skinning.Editor +{ + public class SkinSelectionHandler : SelectionHandler + { + [Resolved] + private SkinEditor skinEditor { get; set; } + + public override bool HandleRotation(float angle) + { + if (SelectedBlueprints.Count == 1) + { + // for single items, rotate around the origin rather than the selection centre. + ((Drawable)SelectedBlueprints.First().Item).Rotation += angle; + } + else + { + var selectionQuad = getSelectionQuad(); + + foreach (var b in SelectedBlueprints) + { + var drawableItem = (Drawable)b.Item; + + var rotatedPosition = RotatePointAroundOrigin(b.ScreenSpaceSelectionPoint, selectionQuad.Centre, angle); + updateDrawablePosition(drawableItem, rotatedPosition); + + drawableItem.Rotation += angle; + } + } + + // this isn't always the case but let's be lenient for now. + return true; + } + + public override bool HandleScale(Vector2 scale, Anchor anchor) + { + // convert scale to screen space + scale = ToScreenSpace(scale) - ToScreenSpace(Vector2.Zero); + + adjustScaleFromAnchor(ref scale, anchor); + + // the selection quad is always upright, so use an AABB rect to make mutating the values easier. + var selectionRect = getSelectionQuad().AABBFloat; + + // If the selection has no area we cannot scale it + if (selectionRect.Area == 0) + return false; + + // copy to mutate, as we will need to compare to the original later on. + var adjustedRect = selectionRect; + + // first, remove any scale axis we are not interested in. + if (anchor.HasFlagFast(Anchor.x1)) scale.X = 0; + if (anchor.HasFlagFast(Anchor.y1)) scale.Y = 0; + + bool shouldAspectLock = + // for now aspect lock scale adjustments that occur at corners.. + (!anchor.HasFlagFast(Anchor.x1) && !anchor.HasFlagFast(Anchor.y1)) + // ..or if any of the selection have been rotated. + // this is to avoid requiring skew logic (which would likely not be the user's expected transform anyway). + || SelectedBlueprints.Any(b => !Precision.AlmostEquals(((Drawable)b.Item).Rotation, 0)); + + if (shouldAspectLock) + { + if (anchor.HasFlagFast(Anchor.x1)) + // if dragging from the horizontal centre, only a vertical component is available. + scale.X = scale.Y / selectionRect.Height * selectionRect.Width; + else + // in all other cases (arbitrarily) use the horizontal component for aspect lock. + scale.Y = scale.X / selectionRect.Width * selectionRect.Height; + } + + if (anchor.HasFlagFast(Anchor.x0)) adjustedRect.X -= scale.X; + if (anchor.HasFlagFast(Anchor.y0)) adjustedRect.Y -= scale.Y; + + adjustedRect.Width += scale.X; + adjustedRect.Height += scale.Y; + + // scale adjust applied to each individual item should match that of the quad itself. + var scaledDelta = new Vector2( + adjustedRect.Width / selectionRect.Width, + adjustedRect.Height / selectionRect.Height + ); + + foreach (var b in SelectedBlueprints) + { + var drawableItem = (Drawable)b.Item; + + // each drawable's relative position should be maintained in the scaled quad. + var screenPosition = b.ScreenSpaceSelectionPoint; + + var relativePositionInOriginal = + new Vector2( + (screenPosition.X - selectionRect.TopLeft.X) / selectionRect.Width, + (screenPosition.Y - selectionRect.TopLeft.Y) / selectionRect.Height + ); + + var newPositionInAdjusted = new Vector2( + adjustedRect.TopLeft.X + adjustedRect.Width * relativePositionInOriginal.X, + adjustedRect.TopLeft.Y + adjustedRect.Height * relativePositionInOriginal.Y + ); + + updateDrawablePosition(drawableItem, newPositionInAdjusted); + drawableItem.Scale *= scaledDelta; + } + + return true; + } + + public override bool HandleFlip(Direction direction) + { + var selectionQuad = getSelectionQuad(); + + foreach (var b in SelectedBlueprints) + { + var drawableItem = (Drawable)b.Item; + + var flippedPosition = GetFlippedPosition(direction, selectionQuad, b.ScreenSpaceSelectionPoint); + + updateDrawablePosition(drawableItem, flippedPosition); + + drawableItem.Scale *= new Vector2( + direction == Direction.Horizontal ? -1 : 1, + direction == Direction.Vertical ? -1 : 1 + ); + } + + return true; + } + + public override bool HandleMovement(MoveSelectionEvent moveEvent) + { + foreach (var c in SelectedBlueprints) + { + Drawable drawable = (Drawable)c.Item; + drawable.Position += drawable.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); + } + + return true; + } + + protected override void OnSelectionChanged() + { + base.OnSelectionChanged(); + + SelectionBox.CanRotate = true; + SelectionBox.CanScaleX = true; + SelectionBox.CanScaleY = true; + SelectionBox.CanReverse = false; + } + + protected override void DeleteItems(IEnumerable items) => + skinEditor.DeleteItems(items.ToArray()); + + protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) + { + yield return new OsuMenuItem("Anchor") + { + Items = createAnchorItems(d => d.Anchor, applyAnchor).ToArray() + }; + + yield return new OsuMenuItem("Origin") + { + Items = createAnchorItems(d => d.Origin, applyOrigin).ToArray() + }; + + foreach (var item in base.GetContextMenuItemsForSelection(selection)) + yield return item; + + IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) + { + var displayableAnchors = new[] + { + Anchor.TopLeft, + Anchor.TopCentre, + Anchor.TopRight, + Anchor.CentreLeft, + Anchor.Centre, + Anchor.CentreRight, + Anchor.BottomLeft, + Anchor.BottomCentre, + Anchor.BottomRight, + }; + + return displayableAnchors.Select(a => + { + return new TernaryStateRadioMenuItem(a.ToString(), MenuItemType.Standard, _ => applyFunction(a)) + { + State = { Value = GetStateFromSelection(selection, c => checkFunction((Drawable)c.Item) == a) } + }; + }); + } + } + + private static void updateDrawablePosition(Drawable drawable, Vector2 screenSpacePosition) + { + drawable.Position = + drawable.Parent.ToLocalSpace(screenSpacePosition) - drawable.AnchorPosition; + } + + private void applyOrigin(Anchor anchor) + { + foreach (var item in SelectedItems) + { + var drawable = (Drawable)item; + + var previousOrigin = drawable.OriginPosition; + drawable.Origin = anchor; + drawable.Position += drawable.OriginPosition - previousOrigin; + } + } + + /// + /// A screen-space quad surrounding all selected drawables, accounting for their full displayed size. + /// + /// + private Quad getSelectionQuad() => + GetSurroundingQuad(SelectedBlueprints.SelectMany(b => b.Item.ScreenSpaceDrawQuad.GetVertices().ToArray())); + + private void applyAnchor(Anchor anchor) + { + foreach (var item in SelectedItems) + { + var drawable = (Drawable)item; + + var previousAnchor = drawable.AnchorPosition; + drawable.Anchor = anchor; + drawable.Position -= drawable.AnchorPosition - previousAnchor; + } + } + + private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference) + { + // cancel out scale in axes we don't care about (based on which drag handle was used). + if ((reference & Anchor.x1) > 0) scale.X = 0; + if ((reference & Anchor.y1) > 0) scale.Y = 0; + + // reverse the scale direction if dragging from top or left. + if ((reference & Anchor.x0) > 0) scale.X = -scale.X; + if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y; + } + } +} diff --git a/osu.Game/Skinning/HUDSkinComponent.cs b/osu.Game/Skinning/HUDSkinComponent.cs deleted file mode 100644 index cc053421b7..0000000000 --- a/osu.Game/Skinning/HUDSkinComponent.cs +++ /dev/null @@ -1,22 +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.Linq; - -namespace osu.Game.Skinning -{ - public class HUDSkinComponent : ISkinComponent - { - public readonly HUDSkinComponents Component; - - public HUDSkinComponent(HUDSkinComponents component) - { - Component = component; - } - - protected virtual string ComponentName => Component.ToString(); - - public string LookupName => - string.Join('/', new[] { "HUD", ComponentName }.Where(s => !string.IsNullOrEmpty(s))); - } -} diff --git a/osu.Game/Skinning/HUDSkinComponents.cs b/osu.Game/Skinning/HUDSkinComponents.cs index b01be2d5a0..ea39c98635 100644 --- a/osu.Game/Skinning/HUDSkinComponents.cs +++ b/osu.Game/Skinning/HUDSkinComponents.cs @@ -9,7 +9,8 @@ namespace osu.Game.Skinning ScoreCounter, AccuracyCounter, HealthDisplay, - ScoreText, - ComboText, + SongProgress, + BarHitErrorMeter, + ColourHitErrorMeter, } } diff --git a/osu.Game/Skinning/ISkin.cs b/osu.Game/Skinning/ISkin.cs index ef8de01042..73f7cf6d39 100644 --- a/osu.Game/Skinning/ISkin.cs +++ b/osu.Game/Skinning/ISkin.cs @@ -48,7 +48,7 @@ namespace osu.Game.Skinning /// The requested sample. /// A matching sample channel, or null if unavailable. [CanBeNull] - Sample GetSample(ISampleInfo sampleInfo); + ISample GetSample(ISampleInfo sampleInfo); /// /// Retrieve a configuration value. diff --git a/osu.Game/Screens/Play/HUD/IComboCounter.cs b/osu.Game/Skinning/ISkinnableDrawable.cs similarity index 50% rename from osu.Game/Screens/Play/HUD/IComboCounter.cs rename to osu.Game/Skinning/ISkinnableDrawable.cs index ff235bf04e..d42b6f71b0 100644 --- a/osu.Game/Screens/Play/HUD/IComboCounter.cs +++ b/osu.Game/Skinning/ISkinnableDrawable.cs @@ -1,19 +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.Framework.Bindables; using osu.Framework.Graphics; -namespace osu.Game.Screens.Play.HUD +namespace osu.Game.Skinning { /// - /// An interface providing a set of methods to update a combo counter. + /// Denotes a drawable which, as a drawable, can be adjusted via skinning specifications. /// - public interface IComboCounter : IDrawable + public interface ISkinnableDrawable : IDrawable { /// - /// The current combo to be displayed. + /// Whether this component should be editable by an end user. /// - Bindable Current { get; } + bool IsEditable => true; } } diff --git a/osu.Game/Skinning/ISkinnableTarget.cs b/osu.Game/Skinning/ISkinnableTarget.cs new file mode 100644 index 0000000000..8d4f4dd0c3 --- /dev/null +++ b/osu.Game/Skinning/ISkinnableTarget.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Extensions; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Skinning +{ + /// + /// Denotes a container which can house s. + /// + public interface ISkinnableTarget : IDrawable + { + /// + /// The definition of this target. + /// + SkinnableTarget Target { get; } + + /// + /// A bindable list of components which are being tracked by this skinnable target. + /// + IBindableList Components { get; } + + /// + /// Serialise all children as . + /// + /// The serialised content. + IEnumerable CreateSkinnableInfo() => Components.Select(d => ((Drawable)d).CreateSkinnableInfo()); + + /// + /// Reload this target from the current skin. + /// + void Reload(); + + /// + /// Add a new skinnable component to this target. + /// + /// The component to add. + void Add(ISkinnableDrawable drawable); + + /// + /// Remove an existing skinnable component from this target. + /// + /// The component to remove. + public void Remove(ISkinnableDrawable component); + } +} diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs index 5eda374337..16562d9571 100644 --- a/osu.Game/Skinning/LegacyAccuracyCounter.cs +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -4,44 +4,30 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osuTK; namespace osu.Game.Skinning { - public class LegacyAccuracyCounter : PercentageCounter, IAccuracyCounter + public class LegacyAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { - private readonly ISkin skin; - - public LegacyAccuracyCounter(ISkin skin) + public LegacyAccuracyCounter() { Anchor = Anchor.TopRight; Origin = Anchor.TopRight; Scale = new Vector2(0.6f); Margin = new MarginPadding(10); - - this.skin = skin; } [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } - protected sealed override OsuSpriteText CreateSpriteText() - => (OsuSpriteText)skin?.GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ScoreText)) - ?.With(s => s.Anchor = s.Origin = Anchor.TopRight); - - protected override void Update() + protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(LegacyFont.Score) { - base.Update(); - - if (hud?.ScoreCounter.Drawable is LegacyScoreCounter score) - { - // for now align with the score counter. eventually this will be user customisable. - Y = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.BottomRight).Y; - } - } + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }; } } diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index fb4207b647..caf37e5bc9 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -3,6 +3,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -23,6 +24,25 @@ namespace osu.Game.Skinning Configuration.AllowDefaultComboColoursFallback = false; } + public override Drawable GetDrawableComponent(ISkinComponent component) + { + if (component is SkinnableTargetComponent targetComponent) + { + switch (targetComponent.Target) + { + case SkinnableTarget.MainHUDComponents: + // this should exist in LegacySkin instead, but there isn't a fallback skin for LegacySkins yet. + // therefore keep the check here until fallback default legacy skin is supported. + if (!this.HasFont(LegacyFont.Score)) + return null; + + break; + } + } + + return base.GetDrawableComponent(component); + } + public override IBindable GetConfig(TLookup lookup) { switch (lookup) @@ -39,7 +59,7 @@ namespace osu.Game.Skinning return base.GetConfig(lookup); } - public override Sample GetSample(ISampleInfo sampleInfo) + public override ISample GetSample(ISampleInfo sampleInfo) { if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) { @@ -51,6 +71,6 @@ namespace osu.Game.Skinning } private static SkinInfo createSkinInfo(BeatmapInfo beatmap) => - new SkinInfo { Name = beatmap.ToString(), Creator = beatmap.Metadata.Author.ToString() }; + new SkinInfo { Name = beatmap.ToString(), Creator = beatmap.Metadata?.AuthorString }; } } diff --git a/osu.Game/Skinning/LegacyFont.cs b/osu.Game/Skinning/LegacyFont.cs new file mode 100644 index 0000000000..d1971cb84c --- /dev/null +++ b/osu.Game/Skinning/LegacyFont.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. + +namespace osu.Game.Skinning +{ + /// + /// The type of legacy font to use for s. + /// + public enum LegacyFont + { + Score, + Combo, + HitCircle, + } +} diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index 2921d46467..c601adc3a0 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -16,11 +16,13 @@ using osuTK.Graphics; namespace osu.Game.Skinning { - public class LegacyHealthDisplay : CompositeDrawable, IHealthDisplay + public class LegacyHealthDisplay : HealthDisplay, ISkinnableDrawable { private const double epic_cutoff = 0.5; - private readonly Skin skin; + [Resolved] + private ISkinSource skin { get; set; } + private LegacyHealthPiece fill; private LegacyHealthPiece marker; @@ -28,17 +30,6 @@ namespace osu.Game.Skinning private bool isNewStyle; - public Bindable Current { get; } = new BindableDouble(1) - { - MinValue = 0, - MaxValue = 1 - }; - - public LegacyHealthDisplay(Skin skin) - { - this.skin = skin; - } - [BackgroundDependencyLoader] private void load() { @@ -83,9 +74,9 @@ namespace osu.Game.Skinning marker.Position = fill.Position + new Vector2(fill.DrawWidth, isNewStyle ? fill.DrawHeight / 2 : 0); } - public void Flash(JudgementResult result) => marker.Flash(result); + protected override void Flash(JudgementResult result) => marker.Flash(result); - private static Texture getTexture(Skin skin, string name) => skin.GetTexture($"scorebar-{name}"); + private static Texture getTexture(ISkinSource skin, string name) => skin.GetTexture($"scorebar-{name}"); private static Color4 getFillColour(double hp) { @@ -104,7 +95,7 @@ namespace osu.Game.Skinning private readonly Texture dangerTexture; private readonly Texture superDangerTexture; - public LegacyOldStyleMarker(Skin skin) + public LegacyOldStyleMarker(ISkinSource skin) { normalTexture = getTexture(skin, "ki"); dangerTexture = getTexture(skin, "kidanger"); @@ -135,9 +126,9 @@ namespace osu.Game.Skinning public class LegacyNewStyleMarker : LegacyMarker { - private readonly Skin skin; + private readonly ISkinSource skin; - public LegacyNewStyleMarker(Skin skin) + public LegacyNewStyleMarker(ISkinSource skin) { this.skin = skin; } @@ -159,7 +150,7 @@ namespace osu.Game.Skinning internal class LegacyOldStyleFill : LegacyHealthPiece { - public LegacyOldStyleFill(Skin skin) + public LegacyOldStyleFill(ISkinSource skin) { // required for sizing correctly.. var firstFrame = getTexture(skin, "colour-0"); @@ -182,7 +173,7 @@ namespace osu.Game.Skinning internal class LegacyNewStyleFill : LegacyHealthPiece { - public LegacyNewStyleFill(Skin skin) + public LegacyNewStyleFill(ISkinSource skin) { InternalChild = new Sprite { @@ -254,7 +245,7 @@ namespace osu.Game.Skinning Main.ScaleTo(1.4f).Then().ScaleTo(1, 200, Easing.Out); } - public class LegacyHealthPiece : CompositeDrawable, IHealthDisplay + public class LegacyHealthPiece : CompositeDrawable { public Bindable Current { get; } = new Bindable(); diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index 0a1de461ea..5308640bdd 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -31,8 +31,6 @@ namespace osu.Game.Skinning protected override void ParseLine(List output, Section section, string line) { - line = StripComments(line); - switch (section) { case Section.Mania: diff --git a/osu.Game/Skinning/LegacyRollingCounter.cs b/osu.Game/Skinning/LegacyRollingCounter.cs index 8aa9d4e9af..b531ae1e6f 100644 --- a/osu.Game/Skinning/LegacyRollingCounter.cs +++ b/osu.Game/Skinning/LegacyRollingCounter.cs @@ -4,7 +4,6 @@ using System; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using osuTK; namespace osu.Game.Skinning { @@ -13,28 +12,17 @@ namespace osu.Game.Skinning /// public class LegacyRollingCounter : RollingCounter { - private readonly ISkin skin; - - private readonly string fontName; - private readonly float fontOverlap; + private readonly LegacyFont font; protected override bool IsRollingProportional => true; /// /// Creates a new . /// - /// The from which to get counter number sprites. - /// The name of the legacy font to use. - /// - /// The numeric overlap of number sprites to use. - /// A positive number will bring the number sprites closer together, while a negative number - /// will split them apart more. - /// - public LegacyRollingCounter(ISkin skin, string fontName, float fontOverlap) + /// The legacy font to use for the counter. + public LegacyRollingCounter(LegacyFont font) { - this.skin = skin; - this.fontName = fontName; - this.fontOverlap = fontOverlap; + this.font = font; } protected override double GetProportionalDuration(int currentValue, int newValue) @@ -42,10 +30,6 @@ namespace osu.Game.Skinning return Math.Abs(newValue - currentValue) * 75.0; } - protected sealed override OsuSpriteText CreateSpriteText() => - new LegacySpriteText(skin, fontName) - { - Spacing = new Vector2(-fontOverlap, 0f) - }; + protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(font); } } diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs index 5bffeff5a8..64ea03d59c 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -1,40 +1,32 @@ // 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.Framework.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Play.HUD; using osuTK; namespace osu.Game.Skinning { - public class LegacyScoreCounter : ScoreCounter + public class LegacyScoreCounter : GameplayScoreCounter, ISkinnableDrawable { - private readonly ISkin skin; - protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; - public new Bindable Current { get; } = new Bindable(); - - public LegacyScoreCounter(ISkin skin) + public LegacyScoreCounter() : base(6) { Anchor = Anchor.TopRight; Origin = Anchor.TopRight; - this.skin = skin; - - // base class uses int for display, but externally we bind to ScoreProcessor as a double for now. - Current.BindValueChanged(v => base.Current.Value = (int)v.NewValue); - Scale = new Vector2(0.96f); Margin = new MarginPadding(10); } - protected sealed override OsuSpriteText CreateSpriteText() - => (OsuSpriteText)skin.GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ScoreText)) - .With(s => s.Anchor = s.Origin = Anchor.TopRight); + protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(LegacyFont.Score) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }; } } diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index e5d0217671..20d86ca25d 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -17,8 +17,9 @@ using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; -using osuTK; +using osu.Game.Screens.Play.HUD.HitErrorMeters; using osuTK.Graphics; namespace osu.Game.Skinning @@ -53,13 +54,14 @@ namespace osu.Game.Skinning private readonly Dictionary maniaConfigurations = new Dictionary(); + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] public LegacySkin(SkinInfo skin, IStorageResourceProvider resources) : this(skin, new LegacySkinResourceStore(skin, resources.Files), resources, "skin.ini") { } protected LegacySkin(SkinInfo skin, [CanBeNull] IResourceStore storage, [CanBeNull] IStorageResourceProvider resources, string filename) - : base(skin) + : base(skin, resources) { using (var stream = storage?.GetStream(filename)) { @@ -100,13 +102,6 @@ namespace osu.Game.Skinning true) != null); } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - Textures?.Dispose(); - Samples?.Dispose(); - } - public override IBindable GetConfig(TLookup lookup) { switch (lookup) @@ -327,50 +322,72 @@ namespace osu.Game.Skinning return null; } - private string scorePrefix => GetConfig(LegacySkinConfiguration.LegacySetting.ScorePrefix)?.Value ?? "score"; - - private string comboPrefix => GetConfig(LegacySkinConfiguration.LegacySetting.ComboPrefix)?.Value ?? "score"; - - private bool hasScoreFont => this.HasFont(scorePrefix); - public override Drawable GetDrawableComponent(ISkinComponent component) { + if (base.GetDrawableComponent(component) is Drawable c) + return c; + switch (component) { - case HUDSkinComponent hudComponent: - { - if (!hasScoreFont) - return null; - - switch (hudComponent.Component) + case SkinnableTargetComponent target: + switch (target.Target) { - case HUDSkinComponents.ComboCounter: - return new LegacyComboCounter(); - - case HUDSkinComponents.ScoreCounter: - return new LegacyScoreCounter(this); - - case HUDSkinComponents.AccuracyCounter: - return new LegacyAccuracyCounter(this); - - case HUDSkinComponents.HealthDisplay: - return new LegacyHealthDisplay(this); - - case HUDSkinComponents.ComboText: - return new LegacySpriteText(this, comboPrefix) + case SkinnableTarget.MainHUDComponents: + var skinnableTargetWrapper = new SkinnableTargetComponentsContainer(container => { - Spacing = new Vector2(-(GetConfig(LegacySkinConfiguration.LegacySetting.ComboOverlap)?.Value ?? -2), 0) + var score = container.OfType().FirstOrDefault(); + var accuracy = container.OfType().FirstOrDefault(); + var combo = container.OfType().FirstOrDefault(); + + if (score != null && accuracy != null) + { + accuracy.Y = container.ToLocalSpace(score.ScreenSpaceDrawQuad.BottomRight).Y; + } + + var songProgress = container.OfType().FirstOrDefault(); + + var hitError = container.OfType().FirstOrDefault(); + + if (hitError != null) + { + hitError.Anchor = Anchor.BottomCentre; + hitError.Origin = Anchor.CentreLeft; + hitError.Rotation = -90; + } + + if (songProgress != null) + { + if (hitError != null) hitError.Y -= SongProgress.MAX_HEIGHT; + if (combo != null) combo.Y -= SongProgress.MAX_HEIGHT; + } + }) + { + Children = this.HasFont(LegacyFont.Score) + ? new Drawable[] + { + new LegacyComboCounter(), + new LegacyScoreCounter(), + new LegacyAccuracyCounter(), + new LegacyHealthDisplay(), + new SongProgress(), + new BarHitErrorMeter(), + } + : new Drawable[] + { + // TODO: these should fallback to using osu!classic skin textures, rather than doing this. + new DefaultComboCounter(), + new DefaultScoreCounter(), + new DefaultAccuracyCounter(), + new DefaultHealthDisplay(), + new SongProgress(), + new BarHitErrorMeter(), + } }; - case HUDSkinComponents.ScoreText: - return new LegacySpriteText(this, scorePrefix) - { - Spacing = new Vector2(-(GetConfig(LegacySkinConfiguration.LegacySetting.ScoreOverlap)?.Value ?? -2), 0) - }; + return skinnableTargetWrapper; } return null; - } case GameplaySkinComponent resultComponent: Func createDrawable = () => getJudgementAnimation(resultComponent.Component); @@ -452,7 +469,7 @@ namespace osu.Game.Skinning return null; } - public override Sample GetSample(ISampleInfo sampleInfo) + public override ISample GetSample(ISampleInfo sampleInfo) { IEnumerable lookupNames; @@ -504,5 +521,12 @@ namespace osu.Game.Skinning string lastPiece = componentName.Split('/').Last(); yield return componentName.StartsWith("Gameplay/taiko/", StringComparison.Ordinal) ? "taiko-" + lastPiece : lastPiece; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + Textures?.Dispose(); + Samples?.Dispose(); + } } } diff --git a/osu.Game/Skinning/LegacySkinConfiguration.cs b/osu.Game/Skinning/LegacySkinConfiguration.cs index 84a834ec22..20d1da8aaa 100644 --- a/osu.Game/Skinning/LegacySkinConfiguration.cs +++ b/osu.Game/Skinning/LegacySkinConfiguration.cs @@ -19,6 +19,8 @@ namespace osu.Game.Skinning ComboOverlap, ScorePrefix, ScoreOverlap, + HitCirclePrefix, + HitCircleOverlap, AnimationFramerate, LayeredHitSounds } diff --git a/osu.Game/Skinning/LegacySkinDecoder.cs b/osu.Game/Skinning/LegacySkinDecoder.cs index 75b7ba28b9..2700f84815 100644 --- a/osu.Game/Skinning/LegacySkinDecoder.cs +++ b/osu.Game/Skinning/LegacySkinDecoder.cs @@ -17,8 +17,6 @@ namespace osu.Game.Skinning { if (section != Section.Colours) { - line = StripComments(line); - var pair = SplitKeyVal(line); switch (section) diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index a7c084998d..d8fb1fa664 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.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; @@ -63,8 +64,51 @@ namespace osu.Game.Skinning } } - public static bool HasFont(this ISkin source, string fontPrefix) - => source.GetTexture($"{fontPrefix}-0") != null; + public static bool HasFont(this ISkin source, LegacyFont font) + { + return source.GetTexture($"{source.GetFontPrefix(font)}-0") != null; + } + + public static string GetFontPrefix(this ISkin source, LegacyFont font) + { + switch (font) + { + case LegacyFont.Score: + return source.GetConfig(LegacySetting.ScorePrefix)?.Value ?? "score"; + + case LegacyFont.Combo: + return source.GetConfig(LegacySetting.ComboPrefix)?.Value ?? "score"; + + case LegacyFont.HitCircle: + return source.GetConfig(LegacySetting.HitCirclePrefix)?.Value ?? "default"; + + default: + throw new ArgumentOutOfRangeException(nameof(font)); + } + } + + /// + /// Returns the numeric overlap of number sprites to use. + /// A positive number will bring the number sprites closer together, while a negative number + /// will split them apart more. + /// + public static float GetFontOverlap(this ISkin source, LegacyFont font) + { + switch (font) + { + case LegacyFont.Score: + return source.GetConfig(LegacySetting.ScoreOverlap)?.Value ?? 0f; + + case LegacyFont.Combo: + return source.GetConfig(LegacySetting.ComboOverlap)?.Value ?? 0f; + + case LegacyFont.HitCircle: + return source.GetConfig(LegacySetting.HitCircleOverlap)?.Value ?? -2f; + + default: + throw new ArgumentOutOfRangeException(nameof(font)); + } + } public class SkinnableTextureAnimation : TextureAnimation { diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs index e2f4a82a54..ae8faf1a3b 100644 --- a/osu.Game/Skinning/LegacySkinTransformer.cs +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -34,7 +34,7 @@ namespace osu.Game.Skinning public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => Source.GetTexture(componentName, wrapModeS, wrapModeT); - public virtual Sample GetSample(ISampleInfo sampleInfo) + public virtual ISample GetSample(ISampleInfo sampleInfo) { if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample)) return Source.GetSample(sampleInfo); diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index 5d0e312f7c..7895fcccca 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -2,26 +2,37 @@ // See the LICENCE file in the repository root for full licence text. using System.Threading.Tasks; +using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using osu.Framework.Text; using osu.Game.Graphics.Sprites; +using osuTK; namespace osu.Game.Skinning { - public class LegacySpriteText : OsuSpriteText + public sealed class LegacySpriteText : OsuSpriteText { - private readonly LegacyGlyphStore glyphStore; + private readonly LegacyFont font; + + private LegacyGlyphStore glyphStore; protected override char FixedWidthReferenceCharacter => '5'; protected override char[] FixedWidthExcludeCharacters => new[] { ',', '.', '%', 'x' }; - public LegacySpriteText(ISkin skin, string font = "score") + public LegacySpriteText(LegacyFont font) { + this.font = font; Shadow = false; UseFullGlyphHeight = false; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + Font = new FontUsage(skin.GetFontPrefix(font), 1, fixedWidth: true); + Spacing = new Vector2(-skin.GetFontOverlap(font), 0); - Font = new FontUsage(font, 1, fixedWidth: true); glyphStore = new LegacyGlyphStore(skin); } diff --git a/osu.Game/Skinning/PausableSkinnableSound.cs b/osu.Game/Skinning/PausableSkinnableSound.cs index b3c7c5d6b2..10b8c47028 100644 --- a/osu.Game/Skinning/PausableSkinnableSound.cs +++ b/osu.Game/Skinning/PausableSkinnableSound.cs @@ -16,7 +16,7 @@ namespace osu.Game.Skinning { public double Length => !DrawableSamples.Any() ? 0 : DrawableSamples.Max(sample => sample.Length); - protected bool RequestedPlaying { get; private set; } + public bool RequestedPlaying { get; private set; } public PausableSkinnableSound() { diff --git a/osu.Game/Skinning/PoolableSkinnableSample.cs b/osu.Game/Skinning/PoolableSkinnableSample.cs index abff57091b..b04158a58f 100644 --- a/osu.Game/Skinning/PoolableSkinnableSample.cs +++ b/osu.Game/Skinning/PoolableSkinnableSample.cs @@ -17,7 +17,7 @@ namespace osu.Game.Skinning /// /// A sample corresponding to an that supports being pooled and responding to skin changes. /// - public class PoolableSkinnableSample : SkinReloadableDrawable, IAggregateAudioAdjustment, IAdjustableAudioComponent + public class PoolableSkinnableSample : SkinReloadableDrawable, IAdjustableAudioComponent { /// /// The currently-loaded . @@ -86,21 +86,21 @@ namespace osu.Game.Skinning sampleContainer.Clear(); Sample = null; - var ch = CurrentSkin.GetSample(sampleInfo); + var sample = CurrentSkin.GetSample(sampleInfo); - if (ch == null && AllowDefaultFallback) + if (sample == null && AllowDefaultFallback) { foreach (var lookup in sampleInfo.LookupNames) { - if ((ch = sampleStore.Get(lookup)) != null) + if ((sample = sampleStore.Get(lookup)) != null) break; } } - if (ch == null) + if (sample == null) return; - sampleContainer.Add(Sample = new DrawableSample(ch)); + sampleContainer.Add(Sample = new DrawableSample(sample)); // Start playback internally for the new sample if the previous one was playing beforehand. if (wasPlaying && Looping) @@ -165,6 +165,10 @@ namespace osu.Game.Skinning public BindableNumber Tempo => sampleContainer.Tempo; + public void BindAdjustments(IAggregateAudioAdjustment component) => sampleContainer.BindAdjustments(component); + + public void UnbindAdjustments(IAggregateAudioAdjustment component) => sampleContainer.UnbindAdjustments(component); + public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) => sampleContainer.AddAdjustment(type, adjustBindable); public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) => sampleContainer.RemoveAdjustment(type, adjustBindable); diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index e8d84b49f9..b6cb8fc7a4 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -2,12 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; +using osu.Game.IO; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Skinning { @@ -17,9 +23,11 @@ namespace osu.Game.Skinning public SkinConfiguration Configuration { get; protected set; } - public abstract Drawable GetDrawableComponent(ISkinComponent componentName); + public IDictionary DrawableComponentInfo => drawableComponentInfo; - public abstract Sample GetSample(ISampleInfo sampleInfo); + private readonly Dictionary drawableComponentInfo = new Dictionary(); + + public abstract ISample GetSample(ISampleInfo sampleInfo); public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); @@ -27,15 +35,76 @@ namespace osu.Game.Skinning public abstract IBindable GetConfig(TLookup lookup); - protected Skin(SkinInfo skin) + protected Skin(SkinInfo skin, IStorageResourceProvider resources) { SkinInfo = skin; + + // we may want to move this to some kind of async operation in the future. + foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget))) + { + string filename = $"{skinnableTarget}.json"; + + // skininfo files may be null for default skin. + var fileInfo = SkinInfo.Files?.FirstOrDefault(f => f.Filename == filename); + + if (fileInfo == null) + continue; + + var bytes = resources?.Files.Get(fileInfo.FileInfo.StoragePath); + + if (bytes == null) + continue; + + string jsonContent = Encoding.UTF8.GetString(bytes); + var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + + if (deserializedContent == null) + continue; + + DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray(); + } + } + + /// + /// Remove all stored customisations for the provided target. + /// + /// The target container to reset. + public void ResetDrawableTarget(ISkinnableTarget targetContainer) + { + DrawableComponentInfo.Remove(targetContainer.Target); + } + + /// + /// Update serialised information for the provided target. + /// + /// The target container to serialise to this skin. + public void UpdateDrawableTarget(ISkinnableTarget targetContainer) + { + DrawableComponentInfo[targetContainer.Target] = targetContainer.CreateSkinnableInfo().ToArray(); + } + + public virtual Drawable GetDrawableComponent(ISkinComponent component) + { + switch (component) + { + case SkinnableTargetComponent target: + if (!DrawableComponentInfo.TryGetValue(target.Target, out var skinnableInfo)) + return null; + + return new SkinnableTargetComponentsContainer + { + ChildrenEnumerable = skinnableInfo.Select(i => i.CreateInstance()) + }; + } + + return null; } #region Disposal ~Skin() { + // required to potentially clean up sample store from audio hierarchy. Dispose(false); } diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index aaccbefb3d..e30bc16d8b 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -3,8 +3,11 @@ using System; using System.Collections.Generic; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.IO; namespace osu.Game.Skinning { @@ -22,7 +25,19 @@ namespace osu.Game.Skinning public string Creator { get; set; } - public List Files { get; set; } + public string InstantiationInfo { get; set; } + + public virtual Skin CreateInstance(IStorageResourceProvider resources) + { + var type = string.IsNullOrEmpty(InstantiationInfo) + // handle the case of skins imported before InstantiationInfo was added. + ? typeof(LegacySkin) + : Type.GetType(InstantiationInfo).AsNonNull(); + + return (Skin)Activator.CreateInstance(type, this, resources); + } + + public List Files { get; set; } = new List(); public List Settings { get; set; } @@ -32,7 +47,8 @@ namespace osu.Game.Skinning { ID = DEFAULT_SKIN, Name = "osu!lazer", - Creator = "team osu!" + Creator = "team osu!", + InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() }; public bool Equals(SkinInfo other) => other != null && ID == other.ID; diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 2826c826a5..079c537066 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -6,9 +6,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Linq.Expressions; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; @@ -22,6 +24,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; @@ -34,9 +37,9 @@ namespace osu.Game.Skinning private readonly GameHost host; - private readonly IResourceStore legacyDefaultResources; + private readonly IResourceStore resources; - public readonly Bindable CurrentSkin = new Bindable(new DefaultSkin()); + public readonly Bindable CurrentSkin = new Bindable(new DefaultSkin(null)); public readonly Bindable CurrentSkinInfo = new Bindable(SkinInfo.Default) { Default = SkinInfo.Default }; public override IEnumerable HandledExtensions => new[] { ".osk" }; @@ -45,13 +48,12 @@ namespace osu.Game.Skinning protected override string ImportFromStablePath => "Skins"; - public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, AudioManager audio, IResourceStore legacyDefaultResources) + public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore resources, AudioManager audio) : base(storage, contextFactory, new SkinStore(contextFactory, storage), host) { this.audio = audio; this.host = host; - - this.legacyDefaultResources = legacyDefaultResources; + this.resources = resources; CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue); CurrentSkin.ValueChanged += skin => @@ -86,7 +88,7 @@ namespace osu.Game.Skinning public void SelectRandomSkin() { // choose from only user skins, removing the current selection to ensure a new one is chosen. - var randomChoices = GetAllUsableSkins().Where(s => s.ID > 0 && s.ID != CurrentSkinInfo.Value.ID).ToArray(); + var randomChoices = GetAllUsableSkins().Where(s => s.ID != CurrentSkinInfo.Value.ID).ToArray(); if (randomChoices.Length == 0) { @@ -104,8 +106,8 @@ namespace osu.Game.Skinning protected override string ComputeHash(SkinInfo item, ArchiveReader reader = null) { // we need to populate early to create a hash based off skin.ini contents - if (item.Name?.Contains(".osk") == true) - populateMetadata(item); + if (item.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true) + populateMetadata(item, GetSkin(item)); if (item.Creator != null && item.Creator != unknown_creator_string) { @@ -120,24 +122,26 @@ namespace osu.Game.Skinning protected override async Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) { - await base.Populate(model, archive, cancellationToken); + await base.Populate(model, archive, cancellationToken).ConfigureAwait(false); - if (model.Name?.Contains(".osk") == true) - populateMetadata(model); + var instance = GetSkin(model); + + model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); + + if (model.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true) + populateMetadata(model, instance); } - private void populateMetadata(SkinInfo item) + private void populateMetadata(SkinInfo item, Skin instance) { - Skin reference = GetSkin(item); - - if (!string.IsNullOrEmpty(reference.Configuration.SkinInfo.Name)) + if (!string.IsNullOrEmpty(instance.Configuration.SkinInfo.Name)) { - item.Name = reference.Configuration.SkinInfo.Name; - item.Creator = reference.Configuration.SkinInfo.Creator; + item.Name = instance.Configuration.SkinInfo.Name; + item.Creator = instance.Configuration.SkinInfo.Creator; } else { - item.Name = item.Name.Replace(".osk", ""); + item.Name = item.Name.Replace(".osk", "", StringComparison.OrdinalIgnoreCase); item.Creator ??= unknown_creator_string; } } @@ -147,15 +151,48 @@ namespace osu.Game.Skinning /// /// The skin to lookup. /// A instance correlating to the provided . - public Skin GetSkin(SkinInfo skinInfo) + public Skin GetSkin(SkinInfo skinInfo) => skinInfo.CreateInstance(this); + + /// + /// Ensure that the current skin is in a state it can accept user modifications. + /// This will create a copy of any internal skin and being tracking in the database if not already. + /// + public void EnsureMutableSkin() { - if (skinInfo == SkinInfo.Default) - return new DefaultSkin(); + if (CurrentSkinInfo.Value.ID >= 1) return; - if (skinInfo == DefaultLegacySkin.Info) - return new DefaultLegacySkin(legacyDefaultResources, this); + var skin = CurrentSkin.Value; - return new LegacySkin(skinInfo, this); + // if the user is attempting to save one of the default skin implementations, create a copy first. + CurrentSkinInfo.Value = Import(new SkinInfo + { + Name = skin.SkinInfo.Name + " (modified)", + Creator = skin.SkinInfo.Creator, + InstantiationInfo = skin.SkinInfo.InstantiationInfo, + }).Result; + } + + public void Save(Skin skin) + { + if (skin.SkinInfo.ID <= 0) + throw new InvalidOperationException($"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first."); + + foreach (var drawableInfo in skin.DrawableComponentInfo) + { + string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); + + using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json))) + { + string filename = $"{drawableInfo.Key}.json"; + + var oldFile = skin.SkinInfo.Files.FirstOrDefault(f => f.Filename == filename); + + if (oldFile != null) + ReplaceFile(skin.SkinInfo, oldFile, streamContent, oldFile.Filename); + else + AddFile(skin.SkinInfo, streamContent, filename); + } + } } /// @@ -171,13 +208,14 @@ namespace osu.Game.Skinning public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => CurrentSkin.Value.GetTexture(componentName, wrapModeS, wrapModeT); - public Sample GetSample(ISampleInfo sampleInfo) => CurrentSkin.Value.GetSample(sampleInfo); + public ISample GetSample(ISampleInfo sampleInfo) => CurrentSkin.Value.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => CurrentSkin.Value.GetConfig(lookup); #region IResourceStorageProvider AudioManager IStorageResourceProvider.AudioManager => audio; + IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.Files => Files.Store; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index ba67d0a678..cf22b2e820 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -59,9 +59,9 @@ namespace osu.Game.Skinning return fallbackSource?.GetTexture(componentName, wrapModeS, wrapModeT); } - public Sample GetSample(ISampleInfo sampleInfo) + public ISample GetSample(ISampleInfo sampleInfo) { - Sample sourceChannel; + ISample sourceChannel; if (AllowSampleLookup(sampleInfo) && (sourceChannel = skin?.GetSample(sampleInfo)) != null) return sourceChannel; diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs index 5a48bc4baf..fc2730ca44 100644 --- a/osu.Game/Skinning/SkinnableDrawable.cs +++ b/osu.Game/Skinning/SkinnableDrawable.cs @@ -23,7 +23,7 @@ namespace osu.Game.Skinning /// Whether the drawable component should be centered in available space. /// Defaults to true. /// - public bool CentreComponent { get; set; } = true; + public bool CentreComponent = true; public new Axes AutoSizeAxes { @@ -42,7 +42,8 @@ namespace osu.Game.Skinning /// A function to create the default skin implementation of this element. /// A conditional to decide whether to allow fallback to the default implementation if a skinned element is not present. /// How (if at all) the should be resize to fit within our own bounds. - public SkinnableDrawable(ISkinComponent component, Func defaultImplementation, Func allowFallback = null, ConfineMode confineMode = ConfineMode.NoScaling) + public SkinnableDrawable(ISkinComponent component, Func defaultImplementation = null, Func allowFallback = null, + ConfineMode confineMode = ConfineMode.NoScaling) : this(component, allowFallback, confineMode) { createDefault = defaultImplementation; @@ -68,7 +69,7 @@ namespace osu.Game.Skinning private bool isDefault; - protected virtual Drawable CreateDefault(ISkinComponent component) => createDefault(component); + protected virtual Drawable CreateDefault(ISkinComponent component) => createDefault?.Invoke(component) ?? Empty(); /// /// Whether to apply size restrictions (specified via ) to the default implementation. diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index d3dfcb1dc0..f935adf7a5 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -131,6 +131,15 @@ namespace osu.Game.Skinning }); } + protected override void LoadAsyncComplete() + { + // ensure samples are constructed before SkinChanged() is called via base.LoadAsyncComplete(). + if (!samplesContainer.Any()) + updateSamples(); + + base.LoadAsyncComplete(); + } + /// /// Stops the samples. /// @@ -139,12 +148,6 @@ namespace osu.Game.Skinning samplesContainer.ForEach(c => c.Stop()); } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) - { - base.SkinChanged(skin, allowFallback); - updateSamples(); - } - private void updateSamples() { bool wasPlaying = IsPlaying; @@ -176,14 +179,15 @@ namespace osu.Game.Skinning public BindableNumber Tempo => samplesContainer.Tempo; - public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) - => samplesContainer.AddAdjustment(type, adjustBindable); + public void BindAdjustments(IAggregateAudioAdjustment component) => samplesContainer.BindAdjustments(component); - public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) - => samplesContainer.RemoveAdjustment(type, adjustBindable); + public void UnbindAdjustments(IAggregateAudioAdjustment component) => samplesContainer.UnbindAdjustments(component); - public void RemoveAllAdjustments(AdjustableProperty type) - => samplesContainer.RemoveAllAdjustments(type); + public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) => samplesContainer.AddAdjustment(type, adjustBindable); + + public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) => samplesContainer.RemoveAdjustment(type, adjustBindable); + + public void RemoveAllAdjustments(AdjustableProperty type) => samplesContainer.RemoveAllAdjustments(type); /// /// Whether any samples are currently playing. @@ -192,6 +196,14 @@ namespace osu.Game.Skinning public bool IsPlayed => samplesContainer.Any(s => s.Played); + public IBindable AggregateVolume => samplesContainer.AggregateVolume; + + public IBindable AggregateBalance => samplesContainer.AggregateBalance; + + public IBindable AggregateFrequency => samplesContainer.AggregateFrequency; + + public IBindable AggregateTempo => samplesContainer.AggregateTempo; + #endregion } } diff --git a/osu.Game/Skinning/SkinnableSpriteText.cs b/osu.Game/Skinning/SkinnableSpriteText.cs index 567dd348e1..06461127b1 100644 --- a/osu.Game/Skinning/SkinnableSpriteText.cs +++ b/osu.Game/Skinning/SkinnableSpriteText.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Skinning { @@ -21,9 +22,9 @@ namespace osu.Game.Skinning textDrawable.Text = Text; } - private string text; + private LocalisableString text; - public string Text + public LocalisableString Text { get => text; set diff --git a/osu.Game/Skinning/SkinnableTarget.cs b/osu.Game/Skinning/SkinnableTarget.cs new file mode 100644 index 0000000000..7b1eae126c --- /dev/null +++ b/osu.Game/Skinning/SkinnableTarget.cs @@ -0,0 +1,10 @@ +// 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.Skinning +{ + public enum SkinnableTarget + { + MainHUDComponents + } +} diff --git a/osu.Game/Skinning/SkinnableTargetComponent.cs b/osu.Game/Skinning/SkinnableTargetComponent.cs new file mode 100644 index 0000000000..a17aafe6e7 --- /dev/null +++ b/osu.Game/Skinning/SkinnableTargetComponent.cs @@ -0,0 +1,17 @@ +// 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.Skinning +{ + public class SkinnableTargetComponent : ISkinComponent + { + public readonly SkinnableTarget Target; + + public string LookupName => Target.ToString(); + + public SkinnableTargetComponent(SkinnableTarget target) + { + Target = target; + } + } +} diff --git a/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs new file mode 100644 index 0000000000..2107ca7a8b --- /dev/null +++ b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs @@ -0,0 +1,46 @@ +// 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; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Skinning +{ + /// + /// A container which groups the components of a into a single object. + /// Optionally also applies a default layout to the components. + /// + [Serializable] + public class SkinnableTargetComponentsContainer : Container, ISkinnableDrawable + { + public bool IsEditable => false; + + private readonly Action applyDefaults; + + /// + /// Construct a wrapper with defaults that should be applied once. + /// + /// A function to apply the default layout. + public SkinnableTargetComponentsContainer(Action applyDefaults) + : this() + { + this.applyDefaults = applyDefaults; + } + + [JsonConstructor] + public SkinnableTargetComponentsContainer() + { + RelativeSizeAxes = Axes.Both; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // schedule is required to allow children to run their LoadComplete and take on their correct sizes. + ScheduleAfterChildren(() => applyDefaults?.Invoke(this)); + } + } +} diff --git a/osu.Game/Skinning/SkinnableTargetContainer.cs b/osu.Game/Skinning/SkinnableTargetContainer.cs new file mode 100644 index 0000000000..d454e199dc --- /dev/null +++ b/osu.Game/Skinning/SkinnableTargetContainer.cs @@ -0,0 +1,83 @@ +// 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.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Skinning +{ + public class SkinnableTargetContainer : SkinReloadableDrawable, ISkinnableTarget + { + private SkinnableTargetComponentsContainer content; + + public SkinnableTarget Target { get; } + + public IBindableList Components => components; + + private readonly BindableList components = new BindableList(); + + public SkinnableTargetContainer(SkinnableTarget target) + { + Target = target; + } + + /// + /// Reload all components in this container from the current skin. + /// + public void Reload() + { + ClearInternal(); + components.Clear(); + + content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetComponentsContainer; + + if (content != null) + { + LoadComponentAsync(content, wrapper => + { + AddInternal(wrapper); + components.AddRange(wrapper.Children.OfType()); + }); + } + } + + /// + /// Thrown when attempting to add an element to a target which is not supported by the current skin. + /// Thrown if the provided instance is not a . + public void Add(ISkinnableDrawable component) + { + if (content == null) + throw new NotSupportedException("Attempting to add a new component to a target container which is not supported by the current skin."); + + if (!(component is Drawable drawable)) + throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(component)); + + content.Add(drawable); + components.Add(component); + } + + /// + /// Thrown when attempting to add an element to a target which is not supported by the current skin. + /// Thrown if the provided instance is not a . + public void Remove(ISkinnableDrawable component) + { + if (content == null) + throw new NotSupportedException("Attempting to remove a new component from a target container which is not supported by the current skin."); + + if (!(component is Drawable drawable)) + throw new ArgumentException($"Provided argument must be of type {nameof(Drawable)}.", nameof(component)); + + content.Remove(drawable); + components.Remove(component); + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + + Reload(); + } + } +} diff --git a/osu.Game/Storyboards/CommandTimelineGroup.cs b/osu.Game/Storyboards/CommandTimelineGroup.cs index 6ce3b617e9..c478b91c22 100644 --- a/osu.Game/Storyboards/CommandTimelineGroup.cs +++ b/osu.Game/Storyboards/CommandTimelineGroup.cs @@ -45,11 +45,30 @@ namespace osu.Game.Storyboards }; } + /// + /// Returns the earliest visible time. Will be null unless this group's first command has a start value of zero. + /// + public double? EarliestDisplayedTime + { + get + { + var first = Alpha.Commands.FirstOrDefault(); + + return first?.StartValue == 0 ? first.StartTime : (double?)null; + } + } + [JsonIgnore] public double CommandsStartTime { get { + // if the first alpha command starts at zero it should be given priority over anything else. + // this is due to it creating a state where the target is not present before that time, causing any other events to not be visible. + var earliestDisplay = EarliestDisplayedTime; + if (earliestDisplay != null) + return earliestDisplay.Value; + double min = double.MaxValue; for (int i = 0; i < timelines.Length; i++) diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index af4615c895..ca041da801 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using osuTK; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; @@ -19,6 +20,13 @@ namespace osu.Game.Storyboards.Drawables [Cached] public Storyboard Storyboard { get; } + /// + /// Whether the storyboard is considered finished. + /// + public IBindable HasStoryboardEnded => hasStoryboardEnded; + + private readonly BindableBool hasStoryboardEnded = new BindableBool(); + protected override Container Content { get; } protected override Vector2 DrawScale => new Vector2(Parent.DrawHeight / 480); @@ -39,6 +47,8 @@ namespace osu.Game.Storyboards.Drawables public override bool RemoveCompletedTransforms => false; + private double? lastEventEndTime; + private DependencyContainer dependencies; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => @@ -47,7 +57,13 @@ namespace osu.Game.Storyboards.Drawables public DrawableStoryboard(Storyboard storyboard) { Storyboard = storyboard; + Size = new Vector2(640, 480); + + bool onlyHasVideoElements = Storyboard.Layers.SelectMany(l => l.Elements).Any(e => !(e is StoryboardVideo)); + + Width = Height * (storyboard.BeatmapInfo.WidescreenStoryboard || onlyHasVideoElements ? 16 / 9f : 4 / 3f); + Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -73,6 +89,14 @@ namespace osu.Game.Storyboards.Drawables Add(layer.CreateDrawable()); } + + lastEventEndTime = Storyboard.LatestEventTime; + } + + protected override void Update() + { + base.Update(); + hasStoryboardEnded.Value = lastEventEndTime == null || Time.Current >= lastEventEndTime; } public DrawableStoryboardLayer OverlayLayer => Children.Single(layer => layer.Name == "Overlay"); diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index 7eac994e07..81623a9307 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Textures; @@ -80,17 +81,17 @@ namespace osu.Game.Storyboards.Drawables if (FlipH) { - if (origin.HasFlag(Anchor.x0)) + if (origin.HasFlagFast(Anchor.x0)) origin = Anchor.x2 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); - else if (origin.HasFlag(Anchor.x2)) + else if (origin.HasFlagFast(Anchor.x2)) origin = Anchor.x0 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); } if (FlipV) { - if (origin.HasFlag(Anchor.y0)) + if (origin.HasFlagFast(Anchor.y0)) origin = Anchor.y2 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); - else if (origin.HasFlag(Anchor.y2)) + else if (origin.HasFlagFast(Anchor.y2)) origin = Anchor.y0 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs index 2ada83c3b4..1085b52d65 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs @@ -5,6 +5,7 @@ using System.Threading; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osuTK; namespace osu.Game.Storyboards.Drawables { @@ -15,6 +16,8 @@ namespace osu.Game.Storyboards.Drawables public override bool IsPresent => Enabled && base.IsPresent; + protected LayerElementContainer ElementContainer { get; } + public DrawableStoryboardLayer(StoryboardLayer layer) { Layer = layer; @@ -24,10 +27,10 @@ namespace osu.Game.Storyboards.Drawables Enabled = layer.VisibleWhenPassing; Masking = layer.Masking; - InternalChild = new LayerElementContainer(layer); + InternalChild = ElementContainer = new LayerElementContainer(layer); } - private class LayerElementContainer : LifetimeManagementContainer + protected class LayerElementContainer : LifetimeManagementContainer { private readonly StoryboardLayer storyboardLayer; @@ -35,8 +38,8 @@ namespace osu.Game.Storyboards.Drawables { storyboardLayer = layer; - Width = 640; - Height = 480; + Size = new Vector2(640, 480); + Anchor = Anchor.Centre; Origin = Anchor.Centre; } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs index 7b16009859..fbdd27e762 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs @@ -61,28 +61,32 @@ namespace osu.Game.Storyboards.Drawables { base.Update(); + // Check if we've yet to pass the sample start time. if (Time.Current < sampleInfo.StartTime) { - // We've rewound before the start time of the sample Stop(); - // In the case that the user fast-forwards to a point far beyond the start time of the sample, - // we want to be able to fall into the if-conditional below (therefore we must not have a life time end) + // Playback has stopped, but if the user fast-forwards to a point after the start time of the sample then + // we must not have a lifetime end in order to continue receiving updates and start the sample below. LifetimeStart = sampleInfo.StartTime; LifetimeEnd = double.MaxValue; + + return; } - else if (Time.Current - Time.Elapsed <= sampleInfo.StartTime) + + // Ensure that we've elapsed from a point before the sample's start time before playing. + if (Time.Current - Time.Elapsed <= sampleInfo.StartTime) { // We've passed the start time of the sample. We only play the sample if we're within an allowable range // from the sample's start, to reduce layering if we've been fast-forwarded far into the future if (!RequestedPlaying && Time.Current - sampleInfo.StartTime < allowable_late_start) Play(); - - // In the case that the user rewinds to a point far behind the start time of the sample, - // we want to be able to fall into the if-conditional above (therefore we must not have a life time start) - LifetimeStart = double.MinValue; - LifetimeEnd = sampleInfo.StartTime; } + + // Playback has started, but if the user rewinds to a point before the start time of the sample then + // we must not have a lifetime start in order to continue receiving updates and stop the sample above. + LifetimeStart = double.MinValue; + LifetimeEnd = sampleInfo.StartTime; } } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index 7b1a6d54da..eb877f3dff 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; @@ -80,17 +81,17 @@ namespace osu.Game.Storyboards.Drawables if (FlipH) { - if (origin.HasFlag(Anchor.x0)) + if (origin.HasFlagFast(Anchor.x0)) origin = Anchor.x2 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); - else if (origin.HasFlag(Anchor.x2)) + else if (origin.HasFlagFast(Anchor.x2)) origin = Anchor.x0 | (origin & (Anchor.y0 | Anchor.y1 | Anchor.y2)); } if (FlipV) { - if (origin.HasFlag(Anchor.y0)) + if (origin.HasFlagFast(Anchor.y0)) origin = Anchor.y2 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); - else if (origin.HasFlag(Anchor.y2)) + else if (origin.HasFlagFast(Anchor.y2)) origin = Anchor.y0 | (origin & (Anchor.x0 | Anchor.x1 | Anchor.x2)); } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs index 4ea582ca4a..d746ff5ae5 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs @@ -29,7 +29,7 @@ namespace osu.Game.Storyboards.Drawables [BackgroundDependencyLoader(true)] private void load(IBindable beatmap, TextureStore textureStore) { - var path = beatmap.Value.BeatmapSetInfo?.Files?.Find(f => f.Filename.Equals(Video.Path, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; + var path = beatmap.Value.BeatmapSetInfo?.Files.Find(f => f.Filename.Equals(Video.Path, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; if (path == null) return; diff --git a/osu.Game/Storyboards/IStoryboardElement.cs b/osu.Game/Storyboards/IStoryboardElement.cs index c4c150a8a4..9a059991e6 100644 --- a/osu.Game/Storyboards/IStoryboardElement.cs +++ b/osu.Game/Storyboards/IStoryboardElement.cs @@ -14,4 +14,17 @@ namespace osu.Game.Storyboards Drawable CreateDrawable(); } + + public static class StoryboardElementExtensions + { + /// + /// Returns the end time of this storyboard element. + /// + /// + /// This returns the where available, falling back to otherwise. + /// + /// The storyboard element. + /// The end time of this element. + public static double GetEndTime(this IStoryboardElement element) => (element as IStoryboardElementWithDuration)?.EndTime ?? element.StartTime; + } } diff --git a/osu.Game/Storyboards/IStoryboardElementWithDuration.cs b/osu.Game/Storyboards/IStoryboardElementWithDuration.cs new file mode 100644 index 0000000000..55f163ee07 --- /dev/null +++ b/osu.Game/Storyboards/IStoryboardElementWithDuration.cs @@ -0,0 +1,21 @@ +// 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.Storyboards +{ + /// + /// A that ends at a different time than its start time. + /// + public interface IStoryboardElementWithDuration : IStoryboardElement + { + /// + /// The time at which the ends. + /// + double EndTime { get; } + + /// + /// The duration of the StoryboardElement. + /// + double Duration => EndTime - StartTime; + } +} diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index 1ba25cc11e..38e0e4e38c 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -36,6 +36,16 @@ namespace osu.Game.Storyboards /// public double? EarliestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.StartTime).FirstOrDefault()?.StartTime; + /// + /// Across all layers, find the latest point in time that a storyboard element ends at. + /// Will return null if there are no elements. + /// + /// + /// This iterates all elements and as such should be used sparingly or stored locally. + /// Videos and samples return StartTime as their EndTIme. + /// + public double? LatestEventTime => Layers.SelectMany(l => l.Elements).OrderBy(e => e.GetEndTime()).LastOrDefault()?.GetEndTime(); + /// /// Depth of the currently front-most storyboard layer, excluding the overlay layer. /// @@ -43,7 +53,7 @@ namespace osu.Game.Storyboards public Storyboard() { - layers.Add("Video", new StoryboardLayer("Video", 4, false)); + layers.Add("Video", new StoryboardVideoLayer("Video", 4, false)); layers.Add("Background", new StoryboardLayer("Background", 3)); layers.Add("Fail", new StoryboardLayer("Fail", 2) { VisibleWhenPassing = false, }); layers.Add("Pass", new StoryboardLayer("Pass", 1) { VisibleWhenFailing = false, }); @@ -75,17 +85,13 @@ namespace osu.Game.Storyboards } } - public DrawableStoryboard CreateDrawable(WorkingBeatmap working = null) - { - var drawable = new DrawableStoryboard(this); - drawable.Width = drawable.Height * (BeatmapInfo.WidescreenStoryboard ? 16 / 9f : 4 / 3f); - return drawable; - } + public DrawableStoryboard CreateDrawable(WorkingBeatmap working = null) => + new DrawableStoryboard(this); public Drawable CreateSpriteFromResourcePath(string path, TextureStore textureStore) { Drawable drawable = null; - var storyboardPath = BeatmapInfo.BeatmapSet?.Files?.Find(f => f.Filename.Equals(path, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; + var storyboardPath = BeatmapInfo.BeatmapSet?.Files.Find(f => f.Filename.Equals(path, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; if (storyboardPath != null) drawable = new Sprite { Texture = textureStore.Get(storyboardPath) }; diff --git a/osu.Game/Storyboards/StoryboardLayer.cs b/osu.Game/Storyboards/StoryboardLayer.cs index 1cde7cf67a..fa9d4ebfea 100644 --- a/osu.Game/Storyboards/StoryboardLayer.cs +++ b/osu.Game/Storyboards/StoryboardLayer.cs @@ -32,7 +32,7 @@ namespace osu.Game.Storyboards Elements.Add(element); } - public DrawableStoryboardLayer CreateDrawable() + public virtual DrawableStoryboardLayer CreateDrawable() => new DrawableStoryboardLayer(this) { Depth = Depth, Name = Name }; } } diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index f411ad04f3..bf87e7d10e 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -11,7 +11,7 @@ using JetBrains.Annotations; namespace osu.Game.Storyboards { - public class StoryboardSprite : IStoryboardElement + public class StoryboardSprite : IStoryboardElementWithDuration { private readonly List loops = new List(); private readonly List triggers = new List(); @@ -24,13 +24,46 @@ namespace osu.Game.Storyboards public readonly CommandTimelineGroup TimelineGroup = new CommandTimelineGroup(); - public double StartTime => Math.Min( - TimelineGroup.HasCommands ? TimelineGroup.CommandsStartTime : double.MaxValue, - loops.Any(l => l.HasCommands) ? loops.Where(l => l.HasCommands).Min(l => l.StartTime) : double.MaxValue); + public double StartTime + { + get + { + // check for presence affecting commands as an initial pass. + double earliestStartTime = TimelineGroup.EarliestDisplayedTime ?? double.MaxValue; - public double EndTime => Math.Max( - TimelineGroup.HasCommands ? TimelineGroup.CommandsEndTime : double.MinValue, - loops.Any(l => l.HasCommands) ? loops.Where(l => l.HasCommands).Max(l => l.EndTime) : double.MinValue); + foreach (var l in loops) + { + if (!(l.EarliestDisplayedTime is double lEarliest)) + continue; + + earliestStartTime = Math.Min(earliestStartTime, lEarliest); + } + + if (earliestStartTime < double.MaxValue) + return earliestStartTime; + + // if an alpha-affecting command was not found, use the earliest of any command. + earliestStartTime = TimelineGroup.StartTime; + + foreach (var l in loops) + earliestStartTime = Math.Min(earliestStartTime, l.StartTime); + + return earliestStartTime; + } + } + + public double EndTime + { + get + { + double latestEndTime = TimelineGroup.EndTime; + + foreach (var l in loops) + latestEndTime = Math.Max(latestEndTime, l.EndTime); + + return latestEndTime; + } + } public bool HasCommands => TimelineGroup.HasCommands || loops.Any(l => l.HasCommands); diff --git a/osu.Game/Storyboards/StoryboardVideoLayer.cs b/osu.Game/Storyboards/StoryboardVideoLayer.cs new file mode 100644 index 0000000000..2a01c2274a --- /dev/null +++ b/osu.Game/Storyboards/StoryboardVideoLayer.cs @@ -0,0 +1,32 @@ +// 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.Graphics; +using osu.Game.Storyboards.Drawables; +using osuTK; + +namespace osu.Game.Storyboards +{ + public class StoryboardVideoLayer : StoryboardLayer + { + public StoryboardVideoLayer(string name, int depth, bool masking) + : base(name, depth, masking) + { + } + + public override DrawableStoryboardLayer CreateDrawable() + => new DrawableStoryboardVideoLayer(this) { Depth = Depth, Name = Name }; + + public class DrawableStoryboardVideoLayer : DrawableStoryboardLayer + { + public DrawableStoryboardVideoLayer(StoryboardVideoLayer layer) + : base(layer) + { + // for videos we want to take on the full size of the storyboard container hierarchy + // to allow the video to fill the full available region. + ElementContainer.RelativeSizeAxes = Axes.Both; + ElementContainer.Size = Vector2.One; + } + } + } +} diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index fcf20a2eb2..4935f7fc13 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -9,6 +9,7 @@ using System.Reflection; using Newtonsoft.Json; using NUnit.Framework; using osu.Framework.Audio.Track; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; @@ -16,6 +17,7 @@ using osu.Game.IO; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; namespace osu.Game.Tests.Beatmaps { @@ -164,7 +166,7 @@ namespace osu.Game.Tests.Beatmaps private Stream openResource(string name) { - var localPath = Path.GetDirectoryName(Uri.UnescapeDataString(new UriBuilder(Assembly.GetExecutingAssembly().CodeBase).Path)); + var localPath = Path.GetDirectoryName(Uri.UnescapeDataString(new UriBuilder(Assembly.GetExecutingAssembly().CodeBase).Path)).AsNonNull(); return Assembly.LoadFrom(Path.Combine(localPath, $"{ResourceAssembly}.dll")).GetManifestResourceStream($@"{ResourceAssembly}.Resources.{name}"); } @@ -189,7 +191,6 @@ namespace osu.Game.Tests.Beatmaps /// /// Creates the applicable to this . /// - /// protected abstract Ruleset CreateRuleset(); private class ConvertResult @@ -216,6 +217,10 @@ namespace osu.Game.Tests.Beatmaps protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + protected override ISkin GetSkin() => throw new NotImplementedException(); + + public override Stream GetStream(string storagePath) => throw new NotImplementedException(); + protected override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) { var converter = base.CreateBeatmapConverter(beatmap, ruleset); diff --git a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs index 748a52d1c5..76f229a799 100644 --- a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs +++ b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Reflection; using NUnit.Framework; +using osu.Framework.Extensions.ObjectExtensions; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.IO; @@ -30,18 +31,22 @@ namespace osu.Game.Tests.Beatmaps using (var stream = new LineBufferedReader(resStream)) { var decoder = Decoder.GetDecoder(stream); + ((LegacyBeatmapDecoder)decoder).ApplyOffsets = false; - var working = new TestWorkingBeatmap(decoder.Decode(stream)); - working.BeatmapInfo.Ruleset = CreateRuleset().RulesetInfo; - - return working; + return new TestWorkingBeatmap(decoder.Decode(stream)) + { + BeatmapInfo = + { + Ruleset = CreateRuleset().RulesetInfo + } + }; } } private Stream openResource(string name) { - var localPath = Path.GetDirectoryName(Uri.UnescapeDataString(new UriBuilder(Assembly.GetExecutingAssembly().CodeBase).Path)); + var localPath = Path.GetDirectoryName(Uri.UnescapeDataString(new UriBuilder(Assembly.GetExecutingAssembly().CodeBase).Path)).AsNonNull(); return Assembly.LoadFrom(Path.Combine(localPath, $"{ResourceAssembly}.dll")).GetManifestResourceStream($@"{ResourceAssembly}.Resources.{name}"); } diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index 62814d4ed4..7ee6c519b7 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -28,7 +28,7 @@ namespace osu.Game.Tests.Beatmaps [HeadlessTest] public abstract class HitObjectSampleTest : PlayerTestScene, IStorageResourceProvider { - protected abstract IResourceStore Resources { get; } + protected abstract IResourceStore RulesetResources { get; } protected LegacySkin Skin { get; private set; } [Resolved] @@ -75,7 +75,7 @@ namespace osu.Game.Tests.Beatmaps AddStep($"load {filename}", () => { - using (var reader = new LineBufferedReader(Resources.GetStream($"Resources/SampleLookups/{filename}"))) + using (var reader = new LineBufferedReader(RulesetResources.GetStream($"Resources/SampleLookups/{filename}"))) currentTestBeatmap = Decoder.GetDecoder(reader).Decode(reader); // populate ruleset for beatmap converters that require it to be present. @@ -127,6 +127,7 @@ 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; #endregion diff --git a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs index 051ede30b7..0a7fb1483d 100644 --- a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs @@ -21,23 +21,17 @@ namespace osu.Game.Tests.Beatmaps { protected readonly Bindable BeatmapSkins = new Bindable(); protected readonly Bindable BeatmapColours = new Bindable(); + protected ExposedPlayer TestPlayer; - protected WorkingBeatmap TestBeatmap; - public virtual void TestBeatmapComboColours(bool userHasCustomColours, bool useBeatmapSkin) => ConfigureTest(useBeatmapSkin, true, userHasCustomColours); + private WorkingBeatmap testBeatmap; - public virtual void TestBeatmapComboColoursOverride(bool useBeatmapSkin) => ConfigureTest(useBeatmapSkin, false, true); + protected void PrepareBeatmap(Func createBeatmap) => AddStep("prepare beatmap", () => testBeatmap = createBeatmap()); - public virtual void TestBeatmapComboColoursOverrideWithDefaultColours(bool useBeatmapSkin) => ConfigureTest(useBeatmapSkin, false, false); - - public virtual void TestBeatmapNoComboColours(bool useBeatmapSkin, bool useBeatmapColour) => ConfigureTest(useBeatmapSkin, useBeatmapColour, false); - - public virtual void TestBeatmapNoComboColoursSkinOverride(bool useBeatmapSkin, bool useBeatmapColour) => ConfigureTest(useBeatmapSkin, useBeatmapColour, true); - - protected virtual void ConfigureTest(bool useBeatmapSkin, bool useBeatmapColours, bool userHasCustomColours) + protected void ConfigureTest(bool useBeatmapSkin, bool useBeatmapColours, bool userHasCustomColours) { configureSettings(useBeatmapSkin, useBeatmapColours); - AddStep($"load {(((CustomSkinWorkingBeatmap)TestBeatmap).HasColours ? "coloured " : "")} beatmap", () => TestPlayer = LoadBeatmap(userHasCustomColours)); + AddStep("load beatmap", () => TestPlayer = LoadBeatmap(userHasCustomColours)); AddUntilStep("wait for player load", () => TestPlayer.IsLoaded); } @@ -57,7 +51,7 @@ namespace osu.Game.Tests.Beatmaps { ExposedPlayer player; - Beatmap.Value = TestBeatmap; + Beatmap.Value = testBeatmap; LoadScreen(player = CreateTestPlayer(userHasCustomColours)); diff --git a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs index 76f97db59f..54a83f4305 100644 --- a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs @@ -17,7 +17,6 @@ namespace osu.Game.Tests.Beatmaps /// /// Creates the whose legacy mod conversion is to be tested. /// - /// protected abstract Ruleset CreateRuleset(); protected void TestFromLegacy(LegacyMods legacyMods, Type[] expectedMods) diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index fa6dc5647d..2717146c99 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -29,7 +29,6 @@ namespace osu.Game.Tests.Beatmaps BeatmapInfo.Ruleset = ruleset; BeatmapInfo.RulesetID = ruleset.ID ?? 0; BeatmapInfo.BeatmapSet.Metadata = BeatmapInfo.Metadata; - BeatmapInfo.BeatmapSet.Files = new List(); BeatmapInfo.BeatmapSet.Beatmaps = new List { BeatmapInfo }; BeatmapInfo.Length = 75000; BeatmapInfo.OnlineInfo = new BeatmapOnlineInfo(); diff --git a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs index bfcb2403c1..bfce59c7de 100644 --- a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs @@ -1,10 +1,12 @@ // 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 osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; +using osu.Game.Skinning; using osu.Game.Storyboards; namespace osu.Game.Tests.Beatmaps @@ -35,6 +37,10 @@ namespace osu.Game.Tests.Beatmaps protected override Storyboard GetStoryboard() => storyboard ?? base.GetStoryboard(); + protected override ISkin GetSkin() => null; + + public override Stream GetStream(string storagePath) => null; + protected override Texture GetBackground() => null; protected override Track GetBeatmapTrack() => null; diff --git a/osu.Game/Tests/Visual/EditorClockTestScene.cs b/osu.Game/Tests/Visual/EditorClockTestScene.cs index 693c9cb792..34393fba7d 100644 --- a/osu.Game/Tests/Visual/EditorClockTestScene.cs +++ b/osu.Game/Tests/Visual/EditorClockTestScene.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Screens.Edit; @@ -24,7 +23,7 @@ namespace osu.Game.Tests.Visual protected EditorClockTestScene() { - Clock = new EditorClock(new ControlPointInfo(), 5000, BeatDivisor) { IsCoupled = false }; + Clock = new EditorClock(new ControlPointInfo(), BeatDivisor) { IsCoupled = false }; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -46,7 +45,7 @@ namespace osu.Game.Tests.Visual private void beatmapChanged(ValueChangedEvent e) { Clock.ControlPointInfo = e.NewValue.Beatmap.ControlPointInfo; - Clock.ChangeSource((IAdjustableClock)e.NewValue.Track ?? new StopwatchClock()); + Clock.ChangeSource(e.NewValue.Track); Clock.ProcessFrame(); } diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs index a9ee8e2668..d7b02ef797 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -4,11 +4,19 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.IO.Stores; +using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Skinning; namespace osu.Game.Tests.Visual { @@ -20,10 +28,20 @@ namespace osu.Game.Tests.Visual protected EditorClock EditorClock { get; private set; } + /// + /// Whether any saves performed by the editor should be isolate (and not persist) to the underlying . + /// + protected virtual bool IsolateSavingFromDatabase => true; + [BackgroundDependencyLoader] - private void load() + private void load(GameHost host, AudioManager audio, RulesetStore rulesets) { - Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); + var working = CreateWorkingBeatmap(Ruleset.Value); + + Beatmap.Value = working; + + if (IsolateSavingFromDatabase) + Dependencies.CacheAs(new TestBeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default, working)); } protected virtual bool EditorComponentsReady => Editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true @@ -33,12 +51,17 @@ namespace osu.Game.Tests.Visual { base.SetUpSteps(); - AddStep("load editor", () => LoadScreen(Editor = CreateEditor())); + AddStep("load editor", LoadEditor); AddUntilStep("wait for editor to load", () => EditorComponentsReady); AddStep("get beatmap", () => EditorBeatmap = Editor.ChildrenOfType().Single()); AddStep("get clock", () => EditorClock = Editor.ChildrenOfType().Single()); } + protected virtual void LoadEditor() + { + LoadScreen(Editor = CreateEditor()); + } + /// /// Creates the ruleset for providing a corresponding beatmap to load the editor on. /// @@ -65,5 +88,27 @@ namespace osu.Game.Tests.Visual public new bool HasUnsavedChanges => base.HasUnsavedChanges; } + + private class TestBeatmapManager : BeatmapManager + { + private readonly WorkingBeatmap testBeatmap; + + public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host, WorkingBeatmap defaultBeatmap, WorkingBeatmap testBeatmap) + : base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap, false) + { + this.testBeatmap = testBeatmap; + } + + protected override string ComputeHash(BeatmapSetInfo item, ArchiveReader reader = null) + => string.Empty; + + public override WorkingBeatmap GetWorkingBeatmap(BeatmapInfo beatmapInfo) + => testBeatmap; + + public override void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null) + { + // don't actually care about saving for this context. + } + } } } diff --git a/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs index c186525757..b810bbf6ae 100644 --- a/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs +++ b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs @@ -3,7 +3,8 @@ using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.IO.Stores; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Skinning; @@ -12,15 +13,40 @@ namespace osu.Game.Tests.Visual [TestFixture] public abstract class LegacySkinPlayerTestScene : PlayerTestScene { + protected LegacySkin LegacySkin { get; private set; } + private ISkinSource legacySkinSource; protected override TestPlayer CreatePlayer(Ruleset ruleset) => new SkinProvidingPlayer(legacySkinSource); [BackgroundDependencyLoader] - private void load(OsuGameBase game, SkinManager skins) + private void load(SkinManager skins) { - var legacySkin = new DefaultLegacySkin(new NamespacedResourceStore(game.Resources, "Skins/Legacy"), skins); - legacySkinSource = new SkinProvidingContainer(legacySkin); + LegacySkin = new DefaultLegacySkin(skins); + legacySkinSource = new SkinProvidingContainer(LegacySkin); + } + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + addResetTargetsStep(); + } + + [TearDownSteps] + public override void TearDownSteps() + { + addResetTargetsStep(); + base.TearDownSteps(); + } + + private void addResetTargetsStep() + { + AddStep("reset targets", () => this.ChildrenOfType().ForEach(t => + { + LegacySkin.ResetDrawableTarget(t); + t.Reload(); + })); } public class SkinProvidingPlayer : TestPlayer diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index 2e8c834c65..c76d1053b2 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -7,14 +7,19 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Multiplayer { public abstract class MultiplayerTestScene : RoomTestScene { - [Cached(typeof(StatefulMultiplayerClient))] + public const int PLAYER_1_ID = 55; + public const int PLAYER_2_ID = 56; + + [Cached(typeof(MultiplayerClient))] public TestMultiplayerClient Client { get; } [Cached(typeof(IRoomManager))] @@ -48,7 +53,16 @@ namespace osu.Game.Tests.Visual.Multiplayer RoomManager.Schedule(() => RoomManager.PartRoom()); if (joinRoom) + { + Room.Name.Value = "test name"; + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, + Ruleset = { Value = Ruleset.Value } + }); + RoomManager.Schedule(() => RoomManager.CreateRoom(Room)); + } }); public override void SetUpSteps() diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 379bb758c5..b12bd8091d 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -3,12 +3,15 @@ #nullable enable +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.Bindables; +using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -17,14 +20,28 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestMultiplayerClient : StatefulMultiplayerClient + public class TestMultiplayerClient : MultiplayerClient { public override IBindable IsConnected => isConnected; private readonly Bindable isConnected = new Bindable(true); + public Room? APIRoom { get; private set; } + + public Action? RoomSetupAction; + [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private BeatmapManager beatmaps { get; set; } = null!; + + private readonly TestMultiplayerRoomManager roomManager; + + public TestMultiplayerClient(TestMultiplayerRoomManager roomManager) + { + this.roomManager = roomManager; + } + public void Connect() => isConnected.Value = true; public void Disconnect() => isConnected.Value = false; @@ -45,6 +62,12 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } + public void ChangeRoomState(MultiplayerRoomState newState) + { + Debug.Assert(Room != null); + ((IMultiplayerClient)this).RoomStateChanged(newState); + } + public void ChangeUserState(int userId, MultiplayerUserState newState) { Debug.Assert(Room != null); @@ -58,6 +81,7 @@ namespace osu.Game.Tests.Visual.Multiplayer case MultiplayerUserState.Loaded: if (Room.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad)) { + ChangeRoomState(MultiplayerRoomState.Playing); foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.Loaded)) ChangeUserState(u.UserID, MultiplayerUserState.Playing); @@ -69,6 +93,7 @@ namespace osu.Game.Tests.Visual.Multiplayer case MultiplayerUserState.FinishedPlay: if (Room.Users.All(u => u.State != MultiplayerUserState.Playing)) { + ChangeRoomState(MultiplayerRoomState.Open); foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.FinishedPlay)) ChangeUserState(u.UserID, MultiplayerUserState.Results); @@ -89,18 +114,42 @@ namespace osu.Game.Tests.Visual.Multiplayer protected override Task JoinRoom(long roomId) { - var user = new MultiplayerRoomUser(api.LocalUser.Value.Id) { User = api.LocalUser.Value }; + var apiRoom = roomManager.Rooms.Single(r => r.RoomID.Value == roomId); - var room = new MultiplayerRoom(roomId); - room.Users.Add(user); + var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) + { + User = api.LocalUser.Value + }; - if (room.Users.Count == 1) - room.Host = user; + var room = new MultiplayerRoom(roomId) + { + Settings = + { + Name = apiRoom.Name.Value, + BeatmapID = apiRoom.Playlist.Last().BeatmapID, + RulesetID = apiRoom.Playlist.Last().RulesetID, + BeatmapChecksum = apiRoom.Playlist.Last().Beatmap.Value.MD5Hash, + RequiredMods = apiRoom.Playlist.Last().RequiredMods.Select(m => new APIMod(m)).ToArray(), + AllowedMods = apiRoom.Playlist.Last().AllowedMods.Select(m => new APIMod(m)).ToArray(), + PlaylistItemId = apiRoom.Playlist.Last().ID + }, + Users = { localUser }, + Host = localUser + }; + + RoomSetupAction?.Invoke(room); + RoomSetupAction = null; + + APIRoom = apiRoom; return Task.FromResult(room); } - protected override Task LeaveRoomInternal() => Task.CompletedTask; + protected override Task LeaveRoomInternal() + { + APIRoom = null; + return Task.CompletedTask; + } public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); @@ -108,7 +157,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Debug.Assert(Room != null); - await ((IMultiplayerClient)this).SettingsChanged(settings); + await ((IMultiplayerClient)this).SettingsChanged(settings).ConfigureAwait(false); foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready)) ChangeUserState(user.UserID, MultiplayerUserState.Idle); @@ -145,10 +194,25 @@ namespace osu.Game.Tests.Visual.Multiplayer { Debug.Assert(Room != null); + ChangeRoomState(MultiplayerRoomState.WaitingForLoad); foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready)) ChangeUserState(user.UserID, MultiplayerUserState.WaitingForLoad); return ((IMultiplayerClient)this).LoadRequested(); } + + protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + { + Debug.Assert(Room != null); + + var apiRoom = roomManager.Rooms.Single(r => r.RoomID.Value == Room.RoomID); + var set = apiRoom.Playlist.FirstOrDefault(p => p.BeatmapID == beatmapId)?.Beatmap.Value.BeatmapSet + ?? beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == beatmapId)?.BeatmapSet; + + if (set == null) + throw new InvalidOperationException("Beatmap not found."); + + return Task.FromResult(set); + } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs index 860caef071..1abf4d8f5d 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tests.Visual.Multiplayer protected override Container Content => content; private readonly Container content; - [Cached(typeof(StatefulMultiplayerClient))] + [Cached(typeof(MultiplayerClient))] public readonly TestMultiplayerClient Client; [Cached(typeof(IRoomManager))] @@ -32,11 +32,15 @@ namespace osu.Game.Tests.Visual.Multiplayer { RelativeSizeAxes = Axes.Both; + RoomManager = new TestMultiplayerRoomManager(); + Client = new TestMultiplayerClient(RoomManager); + OngoingOperationTracker = new OngoingOperationTracker(); + AddRangeInternal(new Drawable[] { - Client = new TestMultiplayerClient(), - RoomManager = new TestMultiplayerRoomManager(), - OngoingOperationTracker = new OngoingOperationTracker(), + Client, + RoomManager, + OngoingOperationTracker, content = new Container { RelativeSizeAxes = Axes.Both } }); } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs index 5e12156f3c..315be510a3 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Cached] public readonly Bindable Filter = new Bindable(new FilterCriteria()); - private readonly List rooms = new List(); + public new readonly List Rooms = new List(); protected override void LoadComplete() { @@ -35,6 +35,7 @@ namespace osu.Game.Tests.Visual.Multiplayer int currentScoreId = 0; int currentRoomId = 0; + int currentPlaylistItemId = 0; ((DummyAPIAccess)api).HandleRequest = req => { @@ -46,22 +47,25 @@ namespace osu.Game.Tests.Visual.Multiplayer createdRoom.CopyFrom(createRoomRequest.Room); createdRoom.RoomID.Value ??= currentRoomId++; - rooms.Add(createdRoom); + for (int i = 0; i < createdRoom.Playlist.Count; i++) + createdRoom.Playlist[i].ID = currentPlaylistItemId++; + + Rooms.Add(createdRoom); createRoomRequest.TriggerSuccess(createdRoom); - break; + return true; case JoinRoomRequest joinRoomRequest: joinRoomRequest.TriggerSuccess(); - break; + return true; case PartRoomRequest partRoomRequest: partRoomRequest.TriggerSuccess(); - break; + return true; case GetRoomsRequest getRoomsRequest: var roomsWithoutParticipants = new List(); - foreach (var r in rooms) + foreach (var r in Rooms) { var newRoom = new Room(); @@ -72,11 +76,11 @@ namespace osu.Game.Tests.Visual.Multiplayer } getRoomsRequest.TriggerSuccess(roomsWithoutParticipants); - break; + return true; case GetRoomRequest getRoomRequest: - getRoomRequest.TriggerSuccess(rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId)); - break; + getRoomRequest.TriggerSuccess(Rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId)); + return true; case GetBeatmapSetRequest getBeatmapSetRequest: var onlineReq = new GetBeatmapSetRequest(getBeatmapSetRequest.ID, getBeatmapSetRequest.Type); @@ -85,11 +89,11 @@ namespace osu.Game.Tests.Visual.Multiplayer // Get the online API from the game's dependencies. game.Dependencies.Get().Queue(onlineReq); - break; + return true; case CreateRoomScoreRequest createRoomScoreRequest: createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 }); - break; + return true; case SubmitRoomScoreRequest submitRoomScoreRequest: submitRoomScoreRequest.TriggerSuccess(new MultiplayerScore @@ -104,8 +108,10 @@ namespace osu.Game.Tests.Visual.Multiplayer User = api.LocalUser.Value, Statistics = new Dictionary() }); - break; + return true; } + + return false; }; } diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 64f4d7b95b..01dd7a25c8 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -24,18 +24,31 @@ namespace osu.Game.Tests.Visual private readonly TriangleButton buttonTest; private readonly TriangleButton buttonLocal; + /// + /// Whether to create a nested container to handle s that result from local (manual) test input. + /// This should be disabled when instantiating an instance else actions will be lost. + /// + protected virtual bool CreateNestedActionContainer => true; + protected OsuManualInputManagerTestScene() { MenuCursorContainer cursorContainer; + CompositeDrawable mainContent = + (cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }) + .WithChild(content = new OsuTooltipContainer(cursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }); + + if (CreateNestedActionContainer) + { + mainContent = new GlobalActionContainer(null).WithChild(mainContent); + } + base.Content.AddRange(new Drawable[] { InputManager = new ManualInputManager { UseParentInput = true, - Child = new GlobalActionContainer(null) - .WithChild((cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }) - .WithChild(content = new OsuTooltipContainer(cursorContainer.Cursor) { RelativeSizeAxes = Axes.Both })) + Child = mainContent }, new Container { diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 198d22fedd..be9a015ab2 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -14,6 +14,7 @@ using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Framework.Timing; @@ -49,6 +50,8 @@ namespace osu.Game.Tests.Visual private Lazy contextFactory; + protected IResourceStore Resources; + protected IAPIProvider API { get @@ -81,6 +84,8 @@ namespace osu.Game.Tests.Visual if (!UseFreshStoragePerRun) isolatedHostStorage = (parent.Get() as HeadlessGameHost)?.Storage; + Resources = parent.Get().Resources; + contextFactory = new Lazy(() => { var factory = new DatabaseContextFactory(LocalStorage); diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index 78a6bcc3db..2dc77fa72a 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -33,8 +33,12 @@ namespace osu.Game.Tests.Visual protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(new EditorClock()); + var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + dependencies.CacheAs(new EditorBeatmap(playable)); + return dependencies; } diff --git a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs index 1176361679..dc12a4999d 100644 --- a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Tests.Visual { @@ -22,10 +23,11 @@ namespace osu.Game.Tests.Visual }); } - protected void AddBlueprint(OverlaySelectionBlueprint blueprint) + protected void AddBlueprint(HitObjectSelectionBlueprint blueprint, DrawableHitObject drawableObject) { Add(blueprint.With(d => { + d.DrawableObject = drawableObject; d.Depth = float.MinValue; d.Select(); })); diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs index 3d2c68c2ad..ef44d0df24 100644 --- a/osu.Game/Tests/Visual/SkinnableTestScene.cs +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -40,19 +40,19 @@ namespace osu.Game.Tests.Visual } [BackgroundDependencyLoader] - private void load(AudioManager audio, SkinManager skinManager, OsuGameBase game) + private void load(AudioManager audio, SkinManager skinManager) { var dllStore = new DllResourceStore(DynamicCompilationOriginal.GetType().Assembly); metricsSkin = new TestLegacySkin(new SkinInfo { Name = "metrics-skin" }, new NamespacedResourceStore(dllStore, "Resources/metrics_skin"), this, true); - defaultSkin = new DefaultLegacySkin(new NamespacedResourceStore(game.Resources, "Skins/Legacy"), this); + defaultSkin = new DefaultLegacySkin(this); specialSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, new NamespacedResourceStore(dllStore, "Resources/special_skin"), this, true); oldSkin = new TestLegacySkin(new SkinInfo { Name = "old-skin" }, new NamespacedResourceStore(dllStore, "Resources/old_skin"), this, true); } private readonly List createdDrawables = new List(); - public void SetContents(Func creationFunction) + protected void SetContents(Func creationFunction) { createdDrawables.Clear(); @@ -67,9 +67,9 @@ namespace osu.Game.Tests.Visual protected IEnumerable CreatedDrawables => createdDrawables; - private Drawable createProvider(Skin skin, Func creationFunction, IBeatmap beatmap) + private Drawable createProvider(Skin skin, Func creationFunction, IBeatmap beatmap) { - var created = creationFunction(); + var created = creationFunction(skin); createdDrawables.Add(created); @@ -156,6 +156,7 @@ namespace osu.Game.Tests.Visual public AudioManager AudioManager => Audio; public IResourceStore Files => null; + public new IResourceStore Resources => base.Resources; public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); #endregion diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs new file mode 100644 index 0000000000..c7aa43b377 --- /dev/null +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -0,0 +1,113 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Utils; +using osu.Game.Online.API; +using osu.Game.Online.Spectator; +using osu.Game.Replays.Legacy; +using osu.Game.Scoring; + +namespace osu.Game.Tests.Visual.Spectator +{ + public class TestSpectatorClient : SpectatorClient + { + public override IBindable IsConnected { get; } = new Bindable(true); + + private readonly Dictionary userBeatmapDictionary = new Dictionary(); + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + /// + /// Starts play for an arbitrary user. + /// + /// The user to start play for. + /// The playing beatmap id. + public void StartPlay(int userId, int beatmapId) + { + userBeatmapDictionary[userId] = beatmapId; + sendPlayingState(userId); + } + + /// + /// Ends play for an arbitrary user. + /// + /// The user to end play for. + public void EndPlay(int userId) + { + if (!PlayingUsers.Contains(userId)) + return; + + ((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState + { + BeatmapID = userBeatmapDictionary[userId], + RulesetID = 0, + }); + } + + public new void Schedule(Action action) => base.Schedule(action); + + /// + /// Sends frames for an arbitrary user. + /// + /// The user to send frames for. + /// The frame index. + /// The number of frames to send. + public void SendFrames(int userId, int index, int count) + { + var frames = new List(); + + for (int i = index; i < index + count; i++) + { + var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1; + + frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); + } + + var bundle = new FrameDataBundle(new ScoreInfo { Combo = index + count }, frames); + ((ISpectatorClient)this).UserSentFrames(userId, bundle); + } + + protected override Task BeginPlayingInternal(SpectatorState state) + { + // Track the local user's playing beatmap ID. + Debug.Assert(state.BeatmapID != null); + userBeatmapDictionary[api.LocalUser.Value.Id] = state.BeatmapID.Value; + + return ((ISpectatorClient)this).UserBeganPlaying(api.LocalUser.Value.Id, state); + } + + protected override Task SendFramesInternal(FrameDataBundle data) => ((ISpectatorClient)this).UserSentFrames(api.LocalUser.Value.Id, data); + + protected override Task EndPlayingInternal(SpectatorState state) => ((ISpectatorClient)this).UserFinishedPlaying(api.LocalUser.Value.Id, state); + + protected override Task WatchUserInternal(int userId) + { + // When newly watching a user, the server sends the playing state immediately. + if (PlayingUsers.Contains(userId)) + sendPlayingState(userId); + + return Task.CompletedTask; + } + + protected override Task StopWatchingUserInternal(int userId) => Task.CompletedTask; + + private void sendPlayingState(int userId) + { + ((ISpectatorClient)this).UserBeganPlaying(userId, new SpectatorState + { + BeatmapID = userBeatmapDictionary[userId], + RulesetID = 0, + }); + } + } +} diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index f47391ce6a..0addc9de75 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -34,6 +34,8 @@ namespace osu.Game.Tests.Visual public new HealthProcessor HealthProcessor => base.HealthProcessor; + public new bool PauseCooldownActive => base.PauseCooldownActive; + public readonly List Results = new List(); public TestPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs index 4ebf2a7368..50572a7867 100644 --- a/osu.Game/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/SimpleUpdateManager.cs @@ -37,7 +37,7 @@ namespace osu.Game.Updater { var releases = new OsuJsonWebRequest("https://api.github.com/repos/ppy/osu/releases/latest"); - await releases.PerformAsync(); + await releases.PerformAsync().ConfigureAwait(false); var latest = releases.ResponseObject; @@ -77,7 +77,7 @@ namespace osu.Game.Updater bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".exe", StringComparison.Ordinal)); break; - case RuntimeInfo.Platform.MacOsx: + case RuntimeInfo.Platform.macOS: bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip", StringComparison.Ordinal)); break; diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index f772c6d282..1c72f3ebe2 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -52,7 +52,7 @@ namespace osu.Game.Updater // debug / local compilations will reset to a non-release string. // can be useful to check when an install has transitioned between release and otherwise (see OsuConfigManager's migrations). - config.Set(OsuSetting.Version, version); + config.SetValue(OsuSetting.Version, version); } private readonly object updateTaskLock = new object(); @@ -69,7 +69,7 @@ namespace osu.Game.Updater lock (updateTaskLock) waitTask = (updateCheckTask ??= PerformUpdateCheck()); - bool hasUpdates = await waitTask; + bool hasUpdates = await waitTask.ConfigureAwait(false); lock (updateTaskLock) updateCheckTask = null; diff --git a/osu.Game/Users/User.cs b/osu.Game/Users/User.cs index 4a6fd540c7..2e04693e82 100644 --- a/osu.Game/Users/User.cs +++ b/osu.Game/Users/User.cs @@ -72,9 +72,6 @@ namespace osu.Game.Users [JsonProperty(@"support_level")] public int SupportLevel; - [JsonProperty(@"current_mode_rank")] - public int? CurrentModeRank; - [JsonProperty(@"is_gmt")] public bool IsGMT; @@ -114,9 +111,6 @@ namespace osu.Game.Users [JsonProperty(@"twitter")] public string Twitter; - [JsonProperty(@"skype")] - public string Skype; - [JsonProperty(@"discord")] public string Discord; @@ -126,6 +120,9 @@ namespace osu.Game.Users [JsonProperty(@"post_count")] public int PostCount; + [JsonProperty(@"comments_count")] + public int CommentsCount; + [JsonProperty(@"follower_count")] public int FollowerCount; @@ -147,9 +144,15 @@ namespace osu.Game.Users [JsonProperty(@"unranked_beatmapset_count")] public int UnrankedBeatmapsetCount; + [JsonProperty(@"scores_best_count")] + public int ScoresBestCount; + [JsonProperty(@"scores_first_count")] public int ScoresFirstCount; + [JsonProperty(@"scores_recent_count")] + public int ScoresRecentCount; + [JsonProperty(@"beatmap_playcounts_count")] public int BeatmapPlaycountsCount; @@ -182,7 +185,7 @@ namespace osu.Game.Users private UserStatistics statistics; /// - /// User statistics for the requested ruleset (in the case of a response). + /// User statistics for the requested ruleset (in the case of a or response). /// Otherwise empty. /// [JsonProperty(@"statistics")] diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index 78e6f5a05a..5ddcd86d28 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -29,16 +29,9 @@ namespace osu.Game.Users [JsonProperty(@"global_rank")] public int? GlobalRank; + [JsonProperty(@"country_rank")] public int? CountryRank; - [JsonProperty(@"rank")] - private UserRanks ranks - { - // eventually that will also become an own json property instead of reading from a `rank` object. - // see https://github.com/ppy/osu-web/blob/cb79bb72186c8f1a25f6a6f5ef315123decb4231/app/Transformers/UserStatisticsTransformer.php#L53. - set => CountryRank = value.Country; - } - // populated via User model, as that's where the data currently lives. public RankHistoryData RankHistory; @@ -49,10 +42,10 @@ namespace osu.Game.Users public long RankedScore; [JsonProperty(@"hit_accuracy")] - public decimal Accuracy; + public double Accuracy; [JsonIgnore] - public string DisplayAccuracy => Accuracy.FormatAccuracy(); + public string DisplayAccuracy => (Accuracy / 100).FormatAccuracy(); [JsonProperty(@"play_count")] public int PlayCount; @@ -119,13 +112,5 @@ namespace osu.Game.Users } } } - -#pragma warning disable 649 - private struct UserRanks - { - [JsonProperty(@"country")] - public int? Country; - } -#pragma warning restore 649 } } diff --git a/osu.Game/Utils/BatteryInfo.cs b/osu.Game/Utils/BatteryInfo.cs new file mode 100644 index 0000000000..dd9b695e1f --- /dev/null +++ b/osu.Game/Utils/BatteryInfo.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. + +namespace osu.Game.Utils +{ + /// + /// Provides access to the system's power status. + /// + public abstract class BatteryInfo + { + /// + /// The charge level of the battery, from 0 to 1. + /// + public abstract double ChargeLevel { get; } + + public abstract bool IsCharging { get; } + } +} diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index 2578d8d835..df1b6cf00d 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.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; +using System.Globalization; using Humanizer; namespace osu.Game.Utils @@ -10,16 +12,19 @@ namespace osu.Game.Utils /// /// Turns the provided accuracy into a percentage with 2 decimal places. /// - /// The accuracy to be formatted + /// The accuracy to be formatted. + /// An optional format provider. /// formatted accuracy in percentage - public static string FormatAccuracy(this double accuracy) => $"{accuracy:0.00%}"; + public static string FormatAccuracy(this double accuracy, IFormatProvider formatProvider = null) + { + // for the sake of display purposes, we don't want to show a user a "rounded up" percentage to the next whole number. + // ie. a score which gets 89.99999% shouldn't ever show as 90%. + // the reasoning for this is that cutoffs for grade increases are at whole numbers and displaying the required + // percentile with a non-matching grade is confusing. + accuracy = Math.Floor(accuracy * 10000) / 10000; - /// - /// Turns the provided accuracy into a percentage with 2 decimal places. - /// - /// The accuracy to be formatted - /// formatted accuracy in percentage - public static string FormatAccuracy(this decimal accuracy) => $"{accuracy:0.00}%"; + return accuracy.ToString("0.00%", formatProvider ?? CultureInfo.CurrentCulture); + } /// /// Formats the supplied rank/leaderboard position in a consistent, simplified way. diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index c12b5a9fd4..1c3558fc90 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -3,8 +3,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using osu.Framework.Bindables; +using osu.Game.Online.API; using osu.Game.Rulesets.Mods; #nullable enable @@ -89,7 +92,7 @@ namespace osu.Game.Utils /// The mods to check. /// Invalid mods, if any were found. Can be null if all mods were valid. /// Whether the input mods were all valid. If false, will contain all invalid entries. - public static bool CheckValidForGameplay(IEnumerable mods, out List? invalidMods) + public static bool CheckValidForGameplay(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods) { mods = mods.ToArray(); @@ -129,5 +132,38 @@ namespace osu.Game.Utils else yield return mod; } + + /// + /// Returns the underlying value of the given mod setting object. + /// Used in for serialization and equality comparison purposes. + /// + /// The mod setting. + public static object GetSettingUnderlyingValue(object setting) + { + switch (setting) + { + case Bindable d: + return d.Value; + + case Bindable i: + return i.Value; + + case Bindable f: + return f.Value; + + case Bindable b: + return b.Value; + + case IBindable u: + // A mod with unknown (e.g. enum) generic type. + var valueMethod = u.GetType().GetProperty(nameof(IBindable.Value)); + Debug.Assert(valueMethod != null); + return valueMethod.GetValue(u); + + default: + // fall back for non-bindable cases. + return setting; + } + } } } diff --git a/osu.Game/Utils/Optional.cs b/osu.Game/Utils/Optional.cs index 9f8a1c2e62..fdb7623be5 100644 --- a/osu.Game/Utils/Optional.cs +++ b/osu.Game/Utils/Optional.cs @@ -37,7 +37,6 @@ namespace osu.Game.Utils /// Shortcase for: optional.HasValue ? optional.Value : fallback. /// /// The fallback value to return if is false. - /// public T GetOr(T fallback) => HasValue ? Value : fallback; public static implicit operator Optional(T value) => new Optional(value); diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index be9d01cde6..8f12760a6b 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -86,11 +86,6 @@ namespace osu.Game.Utils #region Disposal - ~SentryLogger() - { - Dispose(false); - } - public void Dispose() { Dispose(true); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 4138fc8d6c..49b86ad56e 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -1,4 +1,4 @@ - + netstandard2.1 Library @@ -18,22 +18,27 @@ - - + + + - - - + + + - + - - - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/osu.iOS.props b/osu.iOS.props index 783b638aa0..cbb6a21fd1 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,30 +70,32 @@ - - + + $(NoWarn);NU1605 + - - - - - + + none + + + none + - + - - - - + + + + diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index 5125ad81e0..702aef45f5 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -5,6 +5,8 @@ using System; using Foundation; using osu.Game; using osu.Game.Updater; +using osu.Game.Utils; +using Xamarin.Essentials; namespace osu.iOS { @@ -13,5 +15,14 @@ namespace osu.iOS public override Version AssemblyVersion => new Version(NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString()); protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager(); + + protected override BatteryInfo CreateBatteryInfo() => new IOSBatteryInfo(); + + private class IOSBatteryInfo : BatteryInfo + { + public override double ChargeLevel => Battery.ChargeLevel; + + public override bool IsCharging => Battery.PowerSource != BatteryPowerSource.Battery; + } } } diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index 1e9a21865d..1cbe4422cc 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -116,5 +116,8 @@ false + + + diff --git a/osu.sln b/osu.sln index c9453359b1..b5018db362 100644 --- a/osu.sln +++ b/osu.sln @@ -66,6 +66,34 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Benchmarks", "osu.Game.Benchmarks\osu.Game.Benchmarks.csproj", "{93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Templates", "Templates", "{70CFC05F-CF79-4A7F-81EC-B32F1E564480}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Rulesets", "Rulesets", "{CA1DD4A8-FA22-48E0-860F-D57A7ED7D426}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ruleset-empty", "ruleset-empty", "{6E22BB20-901E-49B3-90A1-B0E6377FE568}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ruleset-example", "ruleset-example", "{7DBBBA73-6D84-4EBA-8711-EBC2939B04B5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ruleset-scrolling-empty", "ruleset-scrolling-empty", "{5CB72FDE-BA77-47D1-A556-FEB15AAD4523}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ruleset-scrolling-example", "ruleset-scrolling-example", "{0E0EDD4C-1E45-4E03-BC08-0102C98D34B3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyFreeform", "Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform\osu.Game.Rulesets.EmptyFreeform.csproj", "{9014CA66-5217-49F6-8C1E-3430FD08EF61}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyFreeform.Tests", "Templates\Rulesets\ruleset-empty\osu.Game.Rulesets.EmptyFreeform.Tests\osu.Game.Rulesets.EmptyFreeform.Tests.csproj", "{561DFD5E-5896-40D1-9708-4D692F5BAE66}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon", "Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj", "{B325271C-85E7-4DB3-8BBB-B70F242954F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon.Tests", "Templates\Rulesets\ruleset-example\osu.Game.Rulesets.Pippidon.Tests\osu.Game.Rulesets.Pippidon.Tests.csproj", "{4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyScrolling", "Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling\osu.Game.Rulesets.EmptyScrolling.csproj", "{AD923016-F318-49B7-B08B-89DED6DC2422}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.EmptyScrolling.Tests", "Templates\Rulesets\ruleset-scrolling-empty\osu.Game.Rulesets.EmptyScrolling.Tests\osu.Game.Rulesets.EmptyScrolling.Tests.csproj", "{B9B92246-02EB-4118-9C6F-85A0D726AA70}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon", "Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon\osu.Game.Rulesets.Pippidon.csproj", "{B9022390-8184-4548-9DB1-50EB8878D20A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Pippidon.Tests", "Templates\Rulesets\ruleset-scrolling-example\osu.Game.Rulesets.Pippidon.Tests\osu.Game.Rulesets.Pippidon.Tests.csproj", "{1743BF7C-E6AE-4A06-BAD9-166D62894303}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -412,6 +440,102 @@ Global {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhone.Build.0 = Release|Any CPU {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|iPhone.Build.0 = Debug|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|Any CPU.Build.0 = Release|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|iPhone.ActiveCfg = Release|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|iPhone.Build.0 = Release|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {9014CA66-5217-49F6-8C1E-3430FD08EF61}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|iPhone.Build.0 = Debug|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|Any CPU.Build.0 = Release|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|iPhone.ActiveCfg = Release|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|iPhone.Build.0 = Release|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {561DFD5E-5896-40D1-9708-4D692F5BAE66}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|iPhone.Build.0 = Debug|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|Any CPU.Build.0 = Release|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|iPhone.ActiveCfg = Release|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|iPhone.Build.0 = Release|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {B325271C-85E7-4DB3-8BBB-B70F242954F8}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|iPhone.Build.0 = Debug|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|Any CPU.Build.0 = Release|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|iPhone.ActiveCfg = Release|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|iPhone.Build.0 = Release|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|iPhone.Build.0 = Debug|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|Any CPU.Build.0 = Release|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|iPhone.ActiveCfg = Release|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|iPhone.Build.0 = Release|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {AD923016-F318-49B7-B08B-89DED6DC2422}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|iPhone.Build.0 = Debug|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|Any CPU.Build.0 = Release|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|iPhone.ActiveCfg = Release|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|iPhone.Build.0 = Release|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {B9B92246-02EB-4118-9C6F-85A0D726AA70}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|iPhone.Build.0 = Debug|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|Any CPU.Build.0 = Release|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|iPhone.ActiveCfg = Release|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|iPhone.Build.0 = Release|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {B9022390-8184-4548-9DB1-50EB8878D20A}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|iPhone.Build.0 = Debug|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|Any CPU.Build.0 = Release|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|iPhone.ActiveCfg = Release|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|iPhone.Build.0 = Release|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {1743BF7C-E6AE-4A06-BAD9-166D62894303}.Release|iPhoneSimulator.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -448,4 +572,19 @@ Global $2.inheritsScope = text/x-csharp $2.scope = text/x-csharp EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} = {70CFC05F-CF79-4A7F-81EC-B32F1E564480} + {6E22BB20-901E-49B3-90A1-B0E6377FE568} = {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} + {9014CA66-5217-49F6-8C1E-3430FD08EF61} = {6E22BB20-901E-49B3-90A1-B0E6377FE568} + {561DFD5E-5896-40D1-9708-4D692F5BAE66} = {6E22BB20-901E-49B3-90A1-B0E6377FE568} + {7DBBBA73-6D84-4EBA-8711-EBC2939B04B5} = {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} + {B325271C-85E7-4DB3-8BBB-B70F242954F8} = {7DBBBA73-6D84-4EBA-8711-EBC2939B04B5} + {4C834F7F-07CA-46C7-8C7B-F10A1B3BC738} = {7DBBBA73-6D84-4EBA-8711-EBC2939B04B5} + {5CB72FDE-BA77-47D1-A556-FEB15AAD4523} = {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} + {0E0EDD4C-1E45-4E03-BC08-0102C98D34B3} = {CA1DD4A8-FA22-48E0-860F-D57A7ED7D426} + {AD923016-F318-49B7-B08B-89DED6DC2422} = {5CB72FDE-BA77-47D1-A556-FEB15AAD4523} + {B9B92246-02EB-4118-9C6F-85A0D726AA70} = {5CB72FDE-BA77-47D1-A556-FEB15AAD4523} + {B9022390-8184-4548-9DB1-50EB8878D20A} = {0E0EDD4C-1E45-4E03-BC08-0102C98D34B3} + {1743BF7C-E6AE-4A06-BAD9-166D62894303} = {0E0EDD4C-1E45-4E03-BC08-0102C98D34B3} + EndGlobalSection EndGlobal diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index aa8f8739c1..62751cebb1 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -18,7 +18,7 @@ WARNING HINT DO_NOT_SHOW - HINT + WARNING WARNING WARNING WARNING @@ -120,6 +120,7 @@ WARNING WARNING HINT + HINT WARNING HINT HINT